srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/wayland/src/decoration
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-02-15 14:56:00 +0200
committersrdusr <[email protected]>2025-02-15 14:56:00 +0200
commit0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd (patch)
tree7672d0af277664f457c6c9462925c0005fe35dcf /crates/wayland/src/decoration
parent413daa7ba2ea0ebd1424c024fd0566423aaea3f8 (diff)
downloadsrdwm-0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd.tar.gz
srdwm-0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd.zip
Checkpoint: preserve all uncommitted rust-rewrite worktree work
Safety commit before reconciling this worktree with main, which has diverged with its own separate fixes today. Nothing here is reviewed or curated yet - this exists purely so none of this work can be lost to a git operation, disk issue, or worktree cleanup while that reconciliation happens.
Diffstat (limited to 'crates/wayland/src/decoration')
-rw-r--r--crates/wayland/src/decoration/border.rs184
-rw-r--r--crates/wayland/src/decoration/buttons.rs291
-rw-r--r--crates/wayland/src/decoration/color.rs34
-rw-r--r--crates/wayland/src/decoration/corners.rs165
-rw-r--r--crates/wayland/src/decoration/font.rs146
-rw-r--r--crates/wayland/src/decoration/shadow.rs186
-rw-r--r--crates/wayland/src/decoration/tests.rs756
-rw-r--r--crates/wayland/src/decoration/titlebar.rs295
8 files changed, 2057 insertions, 0 deletions
diff --git a/crates/wayland/src/decoration/border.rs b/crates/wayland/src/decoration/border.rs
new file mode 100644
index 0000000..472ef3b
--- /dev/null
+++ b/crates/wayland/src/decoration/border.rs
@@ -0,0 +1,184 @@
+//! The four solid-colour strips around a decorated window's own `geometry`
+//! - top/bottom rendered as small rounded bitmaps (their own two outer
+//! corners cut to match `titlebar::render_titlebar`'s), left/right left to
+//! the caller as plain flat fills (`elements::border_side_render_element`).
+
+use super::color::rgb_to_bgra;
+use super::corners::{round_bottom_corners, round_top_corners};
+
+pub fn border_strips(geometry: srdwm_core::Rect, width: u32) -> [srdwm_core::Rect; 4] {
+ let w = width as i32;
+ [
+ srdwm_core::Rect::new(geometry.x - w, geometry.y - w, geometry.width + 2 * width, width),
+ srdwm_core::Rect::new(geometry.x - w, geometry.y + geometry.height as i32, geometry.width + 2 * width, width),
+ srdwm_core::Rect::new(geometry.x - w, geometry.y, width, geometry.height),
+ srdwm_core::Rect::new(geometry.x + geometry.width as i32, geometry.y, width, geometry.height),
+ ]
+}
+
+/// Renders the top border strip (`border_strips`'s first rect) as a BGRA8
+/// bitmap instead of a plain solid fill, with its own outer top corners cut
+/// the same way `titlebar::render_titlebar`'s `round_corners` cuts the
+/// titlebar's - see that parameter's doc comment for why a titlebar rounds
+/// but a square border frame around it used to defeat the point. This
+/// strip's own row 0 sits at the *true* top of the combined titlebar-plus-
+/// border shape (the border is the outermost layer), so it shares the
+/// titlebar's exact same circle - same `radius`, unshifted `center_row` --
+/// rather than a same-centre-different-radius circle of its own; see
+/// `corners::round_top_corners`'s own doc comment for why an earlier
+/// version of this (`radius + thickness` as the radius passed there) drew a
+/// visibly different curve that didn't actually meet the titlebar's at the
+/// seam between them, and `titlebar::render_titlebar`'s call site for the
+/// other half of this pair.
+///
+/// [`render_border_bottom`] gives the bottom strip the matching treatment
+/// for its own two corners.
+///
+/// The buffer is `thickness.max(radius)` rows tall, not always exactly
+/// `thickness` - when `radius > thickness` (true even at this codebase's
+/// own theme defaults, radius 6 over a 4px border), the corner's curve
+/// doesn't finish resolving to flat within just `thickness` rows, and the
+/// left/right strips (`border_side_render_element`, plain flat rectangles
+/// with no curve awareness of their own, starting immediately at this
+/// strip's own bottom edge) have no way to cover the rest of it - the
+/// result was a real, visible wedge of bare background between the
+/// straight border segments and the window's own rounded silhouette,
+/// worse the larger the radius/thickness gap, confirmed live via pixel-
+/// level inspection of a real screenshot (not just reasoned about): the
+/// horizontal top segment only became visible some ~20 columns in from the
+/// corner while the vertical side segment started almost immediately,
+/// exactly the asymmetry a too-short top strip and a curve-blind side
+/// strip produce together. Extending this buffer to the *full* radius
+/// gives the curve enough room to finish, and - since this element draws
+/// on top of the side strips (`render_udev_frame` pushes it first, and
+/// earlier-pushed custom elements composite over later ones) - the extra
+/// rows correctly overpaint the side strip's naive square corner with the
+/// real curve, no changes needed to the side strips or their occlusion-
+/// fragment splitting at all.
+///
+/// Rows past the original `thickness` are real *extra* canvas purely so
+/// the corner columns have room to curve - the middle (non-corner)
+/// columns of those rows sit visually inside where the titlebar/content
+/// begins, not the border ring, so they're forced transparent after
+/// rounding rather than left as solid `color` (which would otherwise paint
+/// a solid border-coloured bar over the titlebar's own left/right edges
+/// for however many rows this extended by).
+pub fn render_border_top(width: u32, thickness: u32, color: (u8, u8, u8), radius: u32) -> Vec<u8> {
+ let (width, thickness) = (width.max(1) as usize, thickness.max(1) as usize);
+ let height = thickness.max(radius as usize).max(1);
+ let bg = rgb_to_bgra(color, 255);
+ let mut buf = vec![0u8; width * height * 4];
+ for px in buf.chunks_exact_mut(4) {
+ px.copy_from_slice(&bg);
+ }
+ round_top_corners(&mut buf, width, height, radius, radius as i32);
+ clip_middle_beyond_thickness(&mut buf, width, radius as usize, thickness..height);
+ buf
+}
+
+/// Zeroes the non-corner (middle) columns of every row in `rows` - the
+/// "extra" rows `render_border_top`/`render_border_bottom` add past their
+/// own true `thickness` purely to give a corner's curve room to resolve.
+/// Left untouched, those rows would stay solid `color` outside the two
+/// corner column-ranges (nothing else clips them), painting a border-
+/// coloured bar across whatever the titlebar/content actually owns there.
+/// `radius` here is the corner column width on each side (already clamped
+/// to `width / 2` inside `corners::round_top_corners`/`round_bottom_
+/// corners`, so this re-derives the same clamp rather than trusting the
+/// caller's raw value).
+fn clip_middle_beyond_thickness(buf: &mut [u8], width: usize, radius: usize, rows: std::ops::Range<usize>) {
+ let r = radius.min(width / 2);
+ if r * 2 >= width {
+ return;
+ }
+ for y in rows {
+ let row = y * width * 4;
+ buf[row + r * 4..row + (width - r) * 4].fill(0);
+ }
+}
+
+/// [`render_border_top`]'s mirror for the bottom strip - same construction,
+/// its own two corners (bottom-left/bottom-right) cut instead. Reported
+/// live, alongside the top-corner work: a bordered window's bottom two
+/// corners still read as square next to the now-rounded top ones, the same
+/// "inconsistently square" complaint that motivated rounding the top strip
+/// in the first place.
+///
+/// Handled as one all-or-nothing bitmap rather than folded into the
+/// left/right strips' per-fragment occlusion splitting (`visible_border_
+/// fragments`) - the same trade-off `render_border_top`'s own call site
+/// already makes and for the same reason: cropping a rounded bitmap's
+/// source rect per fragment is real extra work for a strip this thin.
+pub fn render_border_bottom(width: u32, thickness: u32, color: (u8, u8, u8), radius: u32) -> Vec<u8> {
+ let (width, thickness) = (width.max(1) as usize, thickness.max(1) as usize);
+ let height = thickness.max(radius as usize).max(1);
+ let bg = rgb_to_bgra(color, 255);
+ let mut buf = vec![0u8; width * height * 4];
+ for px in buf.chunks_exact_mut(4) {
+ px.copy_from_slice(&bg);
+ }
+ // Plain `radius`, not `radius + thickness` (what this line passed
+ // before) - that drew the corner against a *larger*, self-invented
+ // circle than the window's own `corner_radius`, the exact same wrong
+ // shape the top strip's own seam fix already rejected for an
+ // equivalent reason (see `corners::round_top_corners`'s doc comment):
+ // sampling only the near-flat tip of an oversized circle produces a
+ // curve that barely bends at all, not one that matches the window's
+ // real corner. `render_border_top` gets `radius` used unshifted here
+ // for the same reason it does: this buffer's own outermost row is
+ // genuinely the true tip of the shape.
+ round_bottom_corners(&mut buf, width, height, radius);
+ // Extra rows sit above the original `thickness`, not below - the
+ // bottom strip's curve resolves going *up* into content, the mirror of
+ // the top strip's resolving *down* into it. See
+ // `clip_middle_beyond_thickness`'s own doc comment for why this needs
+ // to happen at all.
+ clip_middle_beyond_thickness(&mut buf, width, radius as usize, 0..height - thickness);
+ buf
+}
+
+/// Which rows of `render_border_top`'s own buffer a real call site should
+/// actually draw, and how far below the strip's nominal position to start
+/// (`(start_row, row_count, position_shift_down)`) - pulled out as a
+/// plain, backend-independent function so both `udev/render.rs` and
+/// `winit/render.rs` share one tested answer instead of two hand-written
+/// copies that could quietly drift apart.
+///
+/// `decorated` matters because the buffer's own "extra" rows (past
+/// `border_width`, present whenever `corner_radius > border_width`) are
+/// colour-filled at the two corner columns by design - safe to draw only
+/// because a *decorated* window has a titlebar band directly beneath this
+/// strip to receive them (see `render_border_top`'s own doc comment). An
+/// undecorated (CSD) window has no such band: `frame.y` is the top of the
+/// client's own real content, immediately below the nominal `border_width`
+/// rows, so those same extra rows would paint a border-coloured wedge onto
+/// it instead - confirmed live on a real Firefox window, not assumed.
+/// Cropping to just the nominal rows in that case is the fix; the buffer's
+/// own row 0 already sits at the correct position either way (this strip
+/// grows *downward*, unlike its bottom sibling), so the shift is always 0.
+pub(crate) fn border_top_visible_rows(decorated: bool, border_width: u32, corner_radius: u32) -> (u32, u32, u32) {
+ let border_width = border_width.max(1);
+ if decorated {
+ (0, border_width.max(corner_radius), 0)
+ } else {
+ (0, border_width, 0)
+ }
+}
+
+/// [`border_top_visible_rows`]'s mirror for `render_border_bottom`'s own
+/// buffer, whose extra rows sit *above* the nominal ones (growing upward
+/// into content) rather than below - so the nominal, safe-to-draw-
+/// unconditionally rows are the buffer's *last* `border_width` of them,
+/// starting at `start_row = extra`, and skipping them entirely (undecorated
+/// case) also means skipping the compensating downward-shift-into-content
+/// a decorated window's fuller buffer needs, landing back at this strip's
+/// own unshifted nominal position instead.
+pub(crate) fn border_bottom_visible_rows(decorated: bool, border_width: u32, corner_radius: u32) -> (u32, u32, u32) {
+ let border_width = border_width.max(1);
+ let extra = border_width.max(corner_radius) - border_width;
+ if decorated {
+ (0, border_width.max(corner_radius), extra)
+ } else {
+ (extra, border_width, 0)
+ }
+}
diff --git a/crates/wayland/src/decoration/buttons.rs b/crates/wayland/src/decoration/buttons.rs
new file mode 100644
index 0000000..a965ef6
--- /dev/null
+++ b/crates/wayland/src/decoration/buttons.rs
@@ -0,0 +1,291 @@
+//! The three titlebar buttons' own dots and glyphs - traffic-light fill,
+//! glossy shading, and the four hand-drawn glyph shapes (X / dash / square
+//! / zoom-arrows). `titlebar.rs` owns *laying out* the cluster (which
+//! button goes where, which side, how many); everything here just draws
+//! one button once given its own box.
+
+use super::color::rgb_to_bgra;
+
+/// Titlebar buttons are laid out right-aligned in `srdwm_core::BUTTON_PITCH`-
+/// wide squares, vertically centred inside the taller `height` band --
+/// matching `ResizeEdge::hit_test` in `crates/core/src/window.rs`, whose
+/// `BUTTON` constant is also `BUTTON_PITCH`, not `TITLEBAR_HEIGHT` (see that
+/// constant's own doc comment for why the two were split apart). That
+/// function only computes *where* a click on close/maximize/minimize lands;
+/// nothing painted the buttons themselves, so the whole band was one
+/// undifferentiated bar with no visible way to tell where those three
+/// clickable regions were.
+pub(super) const BUTTON_MARGIN: f32 = 0.32;
+/// Smaller margin used only when `buttons_left` is set - explicitly
+/// requested ("bigger" buttons on the left, matching macOS convention).
+/// The button's own *box* stays the same size as the right-aligned case
+/// (see `ResizeEdge::hit_test`'s matching comment on why growing the box
+/// itself would clip against its own pitch); a smaller margin just lets
+/// the dot fill more of that same box. `0.1667` specifically: a button
+/// diameter is `BUTTON_PITCH * (1 - 2 * margin)`, which at `BUTTON_PITCH
+/// = 24` gives a 16px dot - measured directly against a real, live
+/// Firefox window (column-scanned screenshot, edges at 40% luminance
+/// difference from the titlebar background): dot diameter 16px, centre-
+/// to-centre pitch 24px, at this same system's own scale. A previous
+/// `0.25` (12px dot) undershot this - it came from an estimate against a
+/// downloaded reference screenshot rather than a live, same-scale
+/// measurement.
+pub(super) const BUTTON_MARGIN_LEFT: f32 = 0.1667;
+/// How long the titlebar-button glyph-reveal-on-hover animation takes to
+/// reach full opacity - matches real, extracted libadwaita CSS on this
+/// machine almost exactly (`transition: ... 200ms cubic-bezier(...)` on
+/// `windowcontrols > button > image`, found via `gresource extract` on the
+/// installed `.so`, not guessed), even though this project's own default
+/// mode (`ThemeConfig::button_glyph_always`) animates the glyph itself in
+/// rather than Adwaita's own choice of animating the background circle
+/// with the glyph always shown - the *timing* still carries over as the
+/// one piece of real DE precedent either mode can share.
+pub(crate) const HOVER_GLYPH_DURATION: std::time::Duration = std::time::Duration::from_millis(200);
+
+/// Traffic-light button colours (close/minimize/maximize), matching macOS's
+/// own - and, on this machine, matching what Firefox's own CSD already
+/// renders via the WhiteSur GTK theme (confirmed live via `grim`: an
+/// unfocused Firefox window shows the same flat grey dots
+/// `TRAFFIC_LIGHT_INACTIVE` below produces). srdwm's own SSD titlebar used
+/// to draw a plain outline glyph (X/square/dash) in the ordinary text
+/// colour instead - reported live as looking nothing like the traffic-
+/// light buttons every CSD client on this theme already has, and as
+/// visibly different window furniture between, e.g., Firefox (CSD, real
+/// traffic lights) and a terminal (SSD, srdwm's own outline glyphs) side by
+/// side. These are deliberately plain colour constants, not new theme
+/// fields - the accepted, still-open ask is hover-state glyph/highlight
+/// work on top of this base look (see `docs/TODO.md`), not a configurable
+/// palette for it.
+pub(super) const TRAFFIC_LIGHT_CLOSE: (u8, u8, u8) = (0xff, 0x5f, 0x57);
+pub(super) const TRAFFIC_LIGHT_MINIMIZE: (u8, u8, u8) = (0xff, 0xbd, 0x2e);
+pub(super) const TRAFFIC_LIGHT_MAXIMIZE: (u8, u8, u8) = (0x28, 0xc8, 0x40);
+/// Unfocused state for all three buttons - real macOS (and this WhiteSur
+/// theme) dims every traffic light to the same flat grey when its window
+/// isn't active, rather than keeping the colours at reduced opacity.
+pub(super) const TRAFFIC_LIGHT_INACTIVE: (u8, u8, u8) = (0x6e, 0x6e, 0x6e);
+
+/// The `srdwm_core::BUTTON_PITCH`-square box a button's dot is drawn inside,
+/// `offset` pixels in from whichever edge `from_left` selects and centred
+/// vertically inside the taller `height` titlebar band - the box's own
+/// size is the same regardless of side (see `ResizeEdge::hit_test`'s
+/// matching comment on why a *bigger box* on the left would risk the dot
+/// clipping against its own pitch; only the margin, and so the dot within
+/// the same box, actually grows there - `titlebar::render_titlebar`'s own
+/// call site picks `BUTTON_MARGIN`/`BUTTON_MARGIN_LEFT` accordingly).
+pub(super) fn button_box(width: usize, height: usize, offset: usize, from_left: bool, margin: f32) -> (i32, i32, i32, i32) {
+ let square = srdwm_core::BUTTON_PITCH as i32;
+ let inset = (square as f32 * margin).round() as i32;
+ let top = ((height as i32 - square) / 2).max(0);
+ let (left, right) = if from_left { (offset as i32, offset as i32 + square) } else { (width as i32 - offset as i32 - square, width as i32 - offset as i32) };
+ (left + inset, top + inset, right - inset, top + square - inset)
+}
+
+/// Fills a traffic-light dot centred in its button square, anti-aliased the
+/// same `smoothstep` way `corners::blend_corner_pixel` rounds a window's
+/// own corners - a hard-edged circle at this size (typically well under
+/// `TITLEBAR_HEIGHT`, i.e. a ~20px-diameter dot) read as visibly jagged,
+/// the same class of problem the corner-seam fix already solved for a
+/// bigger radius. Unlike that function (which reduces an existing pixel's
+/// alpha to clip it away), this blends *toward* `color` over whatever's
+/// already in `buf` - the titlebar background, always already opaque here
+/// - so the result stays fully opaque at every edge pixel rather than
+/// letting the background show through a soft ring.
+pub(super) fn fill_button_dot(buf: &mut [u8], width: usize, height: usize, offset: usize, from_left: bool, margin: f32, color: (u8, u8, u8)) {
+ let (x0, y0, x1, y1) = button_box(width, height, offset, from_left, margin);
+ let cx = (x0 + x1) as f32 / 2.0;
+ let cy = (y0 + y1) as f32 / 2.0;
+ let radius = ((x1 - x0).min(y1 - y0) as f32 / 2.0).max(0.0);
+ let span = radius.ceil() as i32 + 2;
+ for y in (cy.round() as i32 - span)..=(cy.round() as i32 + span) {
+ if y < 0 || y as usize >= height {
+ continue;
+ }
+ for x in (cx.round() as i32 - span)..=(cx.round() as i32 + span) {
+ if x < 0 || x as usize >= width {
+ continue;
+ }
+ let (dx, dy) = (x as f32 - cx, y as f32 - cy);
+ let dist = (dx * dx + dy * dy).sqrt();
+ let t = ((dist - (radius - 1.0)) / 2.0).clamp(0.0, 1.0);
+ let coverage = 1.0 - (t * t * (3.0 - 2.0 * t));
+ if coverage <= 0.0 {
+ continue;
+ }
+ // Shaded per-pixel, not once for the whole dot - see
+ // `glossy_shade`'s own doc comment for why a flat fill read as
+ // noticeably flatter than real macOS's own traffic lights.
+ let target = rgb_to_bgra(glossy_shade(color, dx, dy, radius.max(1.0)), 255);
+ let idx = (y as usize * width + x as usize) * 4;
+ if coverage >= 1.0 {
+ buf[idx..idx + 4].copy_from_slice(&target);
+ continue;
+ }
+ for c in 0..3 {
+ let existing = buf[idx + c] as f32;
+ buf[idx + c] = (existing + (target[c] as f32 - existing) * coverage).round() as u8;
+ }
+ buf[idx + 3] = 255;
+ }
+ }
+}
+
+/// Shades a flat traffic-light colour into the soft glossy-sphere look real
+/// macOS buttons have, referenced directly against a real screenshot
+/// (Finder's own traffic lights, `~/Downloads`) rather than guessed at: a
+/// gentle highlight toward the upper-left, where its own light source
+/// sits, fading through the flat colour and into a touch of shadow toward
+/// the lower-right rim. Deliberately restrained on both ends - the lit
+/// side never blows out to white and the shadowed side never drops to a
+/// hard black ring - so every dot still reads as its own colour at a
+/// glance, just with real dimensionality instead of a flat fill. `(dx,
+/// dy)` is the pixel's own offset from the dot's centre, in the same units
+/// as `radius`, so this has no dependency on the caller's coordinate
+/// system beyond that.
+fn glossy_shade(color: (u8, u8, u8), dx: f32, dy: f32, radius: f32) -> (u8, u8, u8) {
+ let (nx, ny) = (dx / radius, dy / radius);
+ // Light source up and to the left - the same convention every real
+ // desktop's own icon/button shading already uses.
+ const LIGHT: (f32, f32) = (-0.55, -0.7);
+ const LIGHT_LEN: f32 = 0.888_819_44; // sqrt(0.55^2 + 0.7^2), precomputed
+ let facing = (nx * LIGHT.0 + ny * LIGHT.1) / LIGHT_LEN;
+ // A glossy sphere isn't uniformly lit even on its bright side - it
+ // dims gradually toward every edge, not just the shadowed one.
+ let rim = (nx * nx + ny * ny).min(1.0);
+ let highlight = facing.max(0.0) * (1.0 - rim * 0.4);
+ let shadow = (-facing).max(0.0) * 0.5 + rim * 0.15;
+ let mix_toward = |c: u8, target: f32, amount: f32| (c as f32 + (target - c as f32) * amount).clamp(0.0, 255.0) as u8;
+ let lit = (mix_toward(color.0, 255.0, highlight * 0.45), mix_toward(color.1, 255.0, highlight * 0.45), mix_toward(color.2, 255.0, highlight * 0.45));
+ (mix_toward(lit.0, 0.0, shadow * 0.35), mix_toward(lit.1, 0.0, shadow * 0.35), mix_toward(lit.2, 0.0, shadow * 0.35))
+}
+
+/// A semi-opaque colour blended over whatever's already at `(x, y)`, scaled
+/// by both `alpha` (the glyph-reveal animation's own current progress --
+/// see `tick_hover_glyph_animation`, or a flat 255 in `glyph_always` mode)
+/// and `coverage` (this pixel's own distance-based antialiasing weight from
+/// `blend_glyph_line`, 0..=1). `alpha == 0` is a plain no-op, so a not-yet-
+/// hovered button pays nothing for a glyph nobody can see yet, not even a
+/// fully-transparent draw call. `shade` is the colour blended toward --
+/// near-black for a glyph drawn on a traffic light's own bright fill, or
+/// the titlebar's real foreground colour for one drawn straight on the
+/// titlebar background instead (see `titlebar::render_titlebar`'s own
+/// `glyph_shade` local for which, and why).
+fn blend_glyph_px(buf: &mut [u8], width: usize, height: usize, x: i32, y: i32, alpha: u8, coverage: f32, shade: (u8, u8, u8)) {
+ if x < 0 || y < 0 || x as usize >= width || y as usize >= height || alpha == 0 || coverage <= 0.0 {
+ return;
+ }
+ let idx = (y as usize * width + x as usize) * 4;
+ let a = (alpha as f32 / 255.0) * coverage.min(1.0);
+ for (c, target) in [shade.0, shade.1, shade.2].into_iter().enumerate() {
+ let existing = buf[idx + c] as f32;
+ buf[idx + c] = (existing + (target as f32 - existing) * a).round().clamp(0.0, 255.0) as u8;
+ }
+}
+
+/// Half the glyph stroke's own width, in pixels, before the antialiased
+/// feather outside it - `0.55` reads as a crisp, thin stroke at this dot's
+/// own ~16px scale, matching how thin a real toolkit-rendered traffic-light
+/// glyph actually is. Reported live (a real, live screenshot, not the
+/// downloaded macOS reference this session started from) as visibly too
+/// bold at the previous `1.0` - a real glyph is a hairline, not a stroke
+/// that reads as almost as thick as the dot's own edge AA.
+const GLYPH_HALF_WIDTH: f32 = 0.55;
+
+/// A line segment with a soft, antialiased stroke - a raw Bresenham 1px
+/// line (the original implementation) has hard-stepped, jagged edges on
+/// any diagonal, which stood out badly against every other shape in this
+/// file (`fill_button_dot`, `corners::blend_corner_pixel`) already being
+/// smoothstep-antialiased. Distance-to-segment per candidate pixel, not a
+/// stepped walk, so the diagonal close-glyph "X" gets the same smooth edge
+/// its own button dot does. `shade` - see `blend_glyph_px`'s own doc
+/// comment - passes straight through unchanged.
+fn blend_glyph_line(buf: &mut [u8], width: usize, height: usize, from: (i32, i32), to: (i32, i32), alpha: u8, shade: (u8, u8, u8)) {
+ if alpha == 0 {
+ return;
+ }
+ let (x0, y0) = (from.0 as f32, from.1 as f32);
+ let (x1, y1) = (to.0 as f32, to.1 as f32);
+ let (dx, dy) = (x1 - x0, y1 - y0);
+ let len2 = (dx * dx + dy * dy).max(0.0001);
+ const FEATHER: f32 = 0.7;
+ let reach = (GLYPH_HALF_WIDTH + FEATHER).ceil() as i32;
+ let (xmin, xmax) = (from.0.min(to.0) - reach, from.0.max(to.0) + reach);
+ let (ymin, ymax) = (from.1.min(to.1) - reach, from.1.max(to.1) + reach);
+ for y in ymin..=ymax {
+ if y < 0 || y as usize >= height {
+ continue;
+ }
+ for x in xmin..=xmax {
+ if x < 0 || x as usize >= width {
+ continue;
+ }
+ let t = (((x as f32 - x0) * dx + (y as f32 - y0) * dy) / len2).clamp(0.0, 1.0);
+ let (px, py) = (x0 + t * dx, y0 + t * dy);
+ let dist = ((x as f32 - px).powi(2) + (y as f32 - py).powi(2)).sqrt();
+ let ft = ((dist - GLYPH_HALF_WIDTH) / FEATHER).clamp(0.0, 1.0);
+ let coverage = 1.0 - (ft * ft * (3.0 - 2.0 * ft));
+ blend_glyph_px(buf, width, height, x, y, alpha, coverage, shade);
+ }
+ }
+}
+
+/// The `[0.46]` shrink is deliberate, not arbitrary - the glyph has to sit
+/// visibly *inside* the dot's own circular edge (see `fill_button_dot`),
+/// not touch or cross it, matching real macOS traffic-light glyphs, which
+/// are always noticeably smaller than the button itself. `0.46`, not an
+/// earlier `0.62`: reported live (a rendered dump compared directly
+/// against a real reference screenshot) as too big - a real macOS hover
+/// glyph reads as a small, delicate mark centred in the dot, not a shape
+/// that nearly fills it.
+fn glyph_box(width: usize, height: usize, offset: usize, from_left: bool, margin: f32) -> (i32, i32, i32, i32) {
+ let (x0, y0, x1, y1) = button_box(width, height, offset, from_left, margin);
+ let (cx, cy) = ((x0 + x1) as f32 / 2.0, (y0 + y1) as f32 / 2.0);
+ let half = (x1 - x0).min(y1 - y0) as f32 / 2.0 * 0.46;
+ ((cx - half).round() as i32, (cy - half).round() as i32, (cx + half).round() as i32, (cy + half).round() as i32)
+}
+
+pub(super) fn draw_close_glyph(buf: &mut [u8], width: usize, height: usize, offset: usize, from_left: bool, margin: f32, alpha: u8, shade: (u8, u8, u8)) {
+ let (x0, y0, x1, y1) = glyph_box(width, height, offset, from_left, margin);
+ blend_glyph_line(buf, width, height, (x0, y0), (x1, y1), alpha, shade);
+ blend_glyph_line(buf, width, height, (x0, y1), (x1, y0), alpha, shade);
+}
+
+/// The plain square maximize icon - this project's own original look
+/// (`traffic_lights = false`, a real Windows/GNOME titlebar's own
+/// convention), and still what a traffic-light-style maximize falls back
+/// to if it doesn't get `draw_zoom_glyph` instead. See `titlebar::
+/// render_titlebar`'s own call site for which mode picks which.
+pub(super) fn draw_maximize_glyph(buf: &mut [u8], width: usize, height: usize, offset: usize, from_left: bool, margin: f32, alpha: u8, shade: (u8, u8, u8)) {
+ let (x0, y0, x1, y1) = glyph_box(width, height, offset, from_left, margin);
+ blend_glyph_line(buf, width, height, (x0, y0), (x1, y0), alpha, shade);
+ blend_glyph_line(buf, width, height, (x0, y1), (x1, y1), alpha, shade);
+ blend_glyph_line(buf, width, height, (x0, y0), (x0, y1), alpha, shade);
+ blend_glyph_line(buf, width, height, (x1, y0), (x1, y1), alpha, shade);
+}
+
+pub(super) fn draw_minimize_glyph(buf: &mut [u8], width: usize, height: usize, offset: usize, from_left: bool, margin: f32, alpha: u8, shade: (u8, u8, u8)) {
+ let (x0, y0, x1, y1) = glyph_box(width, height, offset, from_left, margin);
+ let mid = (y0 + y1) / 2;
+ blend_glyph_line(buf, width, height, (x0, mid), (x1, mid), alpha, shade);
+}
+
+/// Real macOS's "zoom" maximize glyph - a double-headed diagonal arrow,
+/// not a square - for `traffic_lights = true` only (see `titlebar::
+/// render_titlebar`'s own call site). One diagonal shaft from the glyph
+/// box's bottom-left to its top-right corner, plus a small two-stroke
+/// arrowhead at each end pointing further outward (away from the glyph's
+/// own centre) - the same primitive (`blend_glyph_line`) every other
+/// glyph here already uses, so this reads as the same family of icon
+/// rather than a different rendering technique bolted on just for this one
+/// shape.
+pub(super) fn draw_zoom_glyph(buf: &mut [u8], width: usize, height: usize, offset: usize, from_left: bool, margin: f32, alpha: u8, shade: (u8, u8, u8)) {
+ let (x0, y0, x1, y1) = glyph_box(width, height, offset, from_left, margin);
+ blend_glyph_line(buf, width, height, (x0, y1), (x1, y0), alpha, shade);
+ let head = (((x1 - x0).max(1) as f32) * 0.4).round() as i32;
+ // Top-right arrowhead, pointing further up-right (away from centre).
+ blend_glyph_line(buf, width, height, (x1, y0), (x1 - head, y0), alpha, shade);
+ blend_glyph_line(buf, width, height, (x1, y0), (x1, y0 + head), alpha, shade);
+ // Bottom-left arrowhead, pointing further down-left (away from centre).
+ blend_glyph_line(buf, width, height, (x0, y1), (x0 + head, y1), alpha, shade);
+ blend_glyph_line(buf, width, height, (x0, y1), (x0, y1 - head), alpha, shade);
+}
+
diff --git a/crates/wayland/src/decoration/color.rs b/crates/wayland/src/decoration/color.rs
new file mode 100644
index 0000000..7ea9d80
--- /dev/null
+++ b/crates/wayland/src/decoration/color.rs
@@ -0,0 +1,34 @@
+//! Small, dependency-free colour helpers shared across every other
+//! `decoration` submodule - converting to the renderer's own byte order,
+//! and the two directional blends (`brighten`/`darken`) button/glyph
+//! shading needs. Nothing here reads a pixel buffer or knows what a
+//! titlebar/border/shadow is; see `buttons.rs` for the module that actually
+//! applies these.
+
+pub(crate) fn rgb_to_bgra(rgb: (u8, u8, u8), alpha: u8) -> [u8; 4] {
+ [rgb.2, rgb.1, rgb.0, alpha]
+}
+
+/// Lightens a button colour for the hover state - see `render_titlebar`'s
+/// `hovered` parameter. Blends toward white rather than just scaling each
+/// channel up, so a fully-saturated channel (e.g. green's `0x00` blue) still
+/// visibly brightens instead of clamping at its own max with nothing left
+/// to move.
+pub(crate) fn brighten(color: (u8, u8, u8)) -> (u8, u8, u8) {
+ const AMOUNT: f32 = 0.35;
+ let mix = |c: u8| (c as f32 + (255.0 - c as f32) * AMOUNT).round() as u8;
+ (mix(color.0), mix(color.1), mix(color.2))
+}
+
+/// Darkens a colour toward black by a fixed fraction - used for a traffic-
+/// light glyph's own shade (see `render_titlebar`'s call site): real macOS
+/// draws each button's glyph as a *darker shade of that same button's own
+/// hue* (a dark red mark on the red button, dark amber on the yellow one),
+/// not one universal near-black tint reused across all three - reported
+/// live as looking too dark/heavy and not colour-matched once compared
+/// directly against a real hover-glyph screenshot.
+pub(crate) fn darken(color: (u8, u8, u8)) -> (u8, u8, u8) {
+ const AMOUNT: f32 = 0.45;
+ let mix = |c: u8| (c as f32 * (1.0 - AMOUNT)).round() as u8;
+ (mix(color.0), mix(color.1), mix(color.2))
+}
diff --git a/crates/wayland/src/decoration/corners.rs b/crates/wayland/src/decoration/corners.rs
new file mode 100644
index 0000000..db32209
--- /dev/null
+++ b/crates/wayland/src/decoration/corners.rs
@@ -0,0 +1,165 @@
+//! Rounding a rasterized titlebar/border bitmap's own top or bottom
+//! corners to a quarter-circle, by fading the pixels outside it to fully
+//! transparent - the CPU-bitmap equivalent of `rounded_corners.rs`'s GLES
+//! fragment shader, for the software-only udev/Pixman render path. Shared
+//! by `titlebar.rs` and `border.rs`, which both cut corners out of their
+//! own otherwise-independent buffers and need the two curves to agree
+//! exactly where they meet.
+
+/// Clips the top-left and top-right corners of a titlebar buffer to a
+/// quarter-circle by making the pixels outside it fully transparent, so
+/// whatever's behind (the desktop, on every top-level window) shows through
+/// instead of a hard square corner.
+///
+/// Only the *top* corners: the titlebar's bottom edge meets the window's
+/// content, which this compositor has no way to clip (content is rendered
+/// entirely by the client) - rounding that seam too would need a
+/// compositor-wide clip mask over arbitrary client buffers, a much larger
+/// change than this cosmetic pass. Real desktops mostly round this the same
+/// way: only the outermost corners of a window, not every internal seam.
+///
+/// Hard cutoff rather than an anti-aliased edge, matching this codebase's
+/// existing pixel-art aesthetic elsewhere (the cursor bitmaps) rather than
+/// mixing rendering styles for one corner treatment.
+///
+/// Zeroes all four BGRA bytes for a cut pixel, not just alpha: this buffer
+/// is `Fourcc::Argb8888`, which both Wayland/`wl_shm` and Pixman treat as
+/// premultiplied - a genuinely transparent premultiplied pixel is `(0, 0,
+/// 0, 0)` in every channel, not just alpha, since the stored colour already
+/// carries the alpha multiplied in. Leaving the opaque titlebar-background
+/// RGB behind while zeroing only alpha produced a byte pattern Pixman's own
+/// `OVER` compositing (`result = src + dst * (1 - src_alpha)`) does not
+/// actually treat as "nothing here": with `src_alpha = 0` the formula still
+/// adds the stale, un-premultiplied `src` RGB straight through, so the
+/// "cut" pixel came out opaque and the corner still read as square --
+/// confirmed live, pixel-by-pixel, no visible transparency anywhere in a
+/// window's real top corner despite this function running and a nonzero
+/// radius. `rounded_corners_pixman.rs`'s `apply_corner_mask` - the
+/// equivalent mask for client *content* - already gets this right (scales
+/// all four bytes together); this was the one corner-rounding path in the
+/// codebase that didn't match it.
+/// `center_row` is which row of *this* buffer's own local coordinates the
+/// corner circle's centre sits on - not always `radius` itself. A plain
+/// titlebar with nothing above it passes `radius as i32` (the ordinary
+/// case: the circle's top tip is this buffer's own row 0, same as this
+/// function always assumed before `center_row` existed). A border-top
+/// strip sitting `thickness` rows *above* the titlebar it visually
+/// continues into needs the *same* radius and the *same* circle - not a
+/// same-centre-different-radius circle of its own, which is what passing
+/// `radius + thickness` here used to do (see the doc comment on
+/// `render_border_top`'s call site for why that was tried first). Two
+/// concentric circles of different radii do not meet smoothly at any
+/// boundary between them: at the exact seam, one buffer's mask is
+/// computed against one radius and the other buffer's mask is computed
+/// one pixel later against a different radius, producing a visible jump
+/// rather than a continuous curve - confirmed live, screenshotted at
+/// actual render resolution, not just reasoned about: the titlebar-to-
+/// border seam showed a hard stepped notch, not a curve. Since a border
+/// strip's own row 0 already sits at the *true* top of the combined
+/// shape, it passes `radius as i32` too (unshifted) - it's the titlebar,
+/// starting `thickness` rows *into* the circle instead of at its top,
+/// that needs to shift, by passing `radius as i32 - border_width as
+/// i32` (see `render_titlebar`'s call site).
+pub(crate) fn round_top_corners(buf: &mut [u8], width: usize, height: usize, radius: u32, center_row: i32) {
+ let r = (radius as usize).min(width / 2);
+ if r == 0 {
+ return;
+ }
+ let rf = r as f32;
+ let cy = center_row as f32;
+ // Only rows that could plausibly need blending at all: below
+ // `center_row` (this buffer's slice of the circle, whatever portion
+ // of it falls within `[0, height)`) is where the actual curve lives;
+ // rows above `center_row - r` or at/below `center_row` are either
+ // already past the transparent tip or already fully inside the
+ // shape, and calling `blend_corner_pixel` there would either be a
+ // wasted no-op (large `dist`, `mask >= 1`) or - critically, for a
+ // *tall* buffer whose straight edge extends far past the corner --
+ // wrongly compute a huge `dist` from being far below the centre and
+ // clip an ordinary straight-edge pixel to transparent. The original
+ // unshifted version of this function avoided that the same way, by
+ // simply never iterating past row `r`; this is that same bound,
+ // generalised to an arbitrary `center_row`.
+ let y_lo = (center_row - r as i32).max(0) as usize;
+ let y_hi = (center_row.max(0) as usize).min(height);
+ for y in y_lo..y_hi {
+ for x in 0..r {
+ blend_corner_pixel(buf, width, x, y, rf, cy, rf);
+ }
+ for x in (width - r)..width {
+ blend_corner_pixel(buf, width, x, y, (width - r - 1) as f32, cy, rf);
+ }
+ }
+}
+
+/// Multiplies the pixel at `(x, y)` by a smoothed 0..1 mask based on its
+/// distance from `(cx, cy)` versus `radius` - `1` (unchanged) well inside
+/// the circle, `0` (fully transparent) well outside it, blended over a ~2px
+/// band at the boundary. Same anti-aliasing technique `rounded_corners.rs`'s
+/// GLES fragment shader already uses for content rounding
+/// (`smoothstep(radius - 1.0, radius + 1.0, dist)`), applied here to a CPU
+/// bitmap pixel by pixel instead of a per-fragment shader.
+///
+/// The previous version of both callers did a hard binary cut instead --
+/// fully opaque or fully transparent, nothing between - which read as a
+/// jagged single-pixel "break" in the border line rather than a curve,
+/// especially in a border strip only a couple of rows tall (the common
+/// case: `border_width` is usually 2-3px) where there's no room for the
+/// eye to average a staircase into something that looks round. Reported
+/// live as "line breaks" right where a window's border met its curved
+/// corner.
+///
+/// `buf` is premultiplied BGRA (`color::rgb_to_bgra`'s own convention), so
+/// scaling all four bytes by the same factor is the correct way to reduce a
+/// pixel's effective alpha - same reasoning
+/// `clipped_corner_pixels_are_fully_premultiplied_zero_not_just_alpha`
+/// already established for the hard-cut case this replaces.
+fn blend_corner_pixel(buf: &mut [u8], width: usize, x: usize, y: usize, cx: f32, cy: f32, radius: f32) {
+ let (dx, dy) = (x as f32 - cx, y as f32 - cy);
+ let dist = (dx * dx + dy * dy).sqrt();
+ let t = ((dist - (radius - 1.0)) / 2.0).clamp(0.0, 1.0);
+ let mask = 1.0 - (t * t * (3.0 - 2.0 * t));
+ if mask >= 1.0 {
+ return;
+ }
+ let idx = (y * width + x) * 4;
+ if mask <= 0.0 {
+ buf[idx..idx + 4].fill(0);
+ return;
+ }
+ for c in &mut buf[idx..idx + 4] {
+ *c = (*c as f32 * mask).round() as u8;
+ }
+}
+
+/// [`round_top_corners`]'s mirror for the bottom two corners - same
+/// construction, corner centres `r` *up* from the bottom instead of down
+/// from the top. Same anti-aliasing, same reason - see
+/// [`blend_corner_pixel`]'s own doc comment.
+pub(crate) fn round_bottom_corners(buf: &mut [u8], width: usize, height: usize, radius: u32) {
+ let r = (radius as usize).min(width / 2);
+ if r == 0 {
+ return;
+ }
+ // See `round_top_corners`' matching comment: `r` (the real corner
+ // radius) must stay unclamped by `height`, or a strip thinner than the
+ // radius cuts its own separate, too-tight arc instead of continuing the
+ // titlebar's. `rows` is just how many of that circle's rows this
+ // buffer actually has room for.
+ let rows = r.min(height);
+ let rf = r as f32;
+ // As a float, not the signed-`i64`-offset trick the hard-cut version
+ // needed to avoid a `usize` underflow - `blend_corner_pixel` already
+ // takes float centres, so `height - r - 1` going negative when `r >
+ // height` (the strip-thinner-than-radius case above) is just a
+ // negative `f32`, no special-casing required.
+ let cy = height as f32 - rf - 1.0;
+ for y in (height - rows)..height {
+ for x in 0..r {
+ blend_corner_pixel(buf, width, x, y, rf, cy, rf);
+ }
+ for x in (width - r)..width {
+ blend_corner_pixel(buf, width, x, y, (width - r - 1) as f32, cy, rf);
+ }
+ }
+}
diff --git a/crates/wayland/src/decoration/font.rs b/crates/wayland/src/decoration/font.rs
new file mode 100644
index 0000000..27df347
--- /dev/null
+++ b/crates/wayland/src/decoration/font.rs
@@ -0,0 +1,146 @@
+//! Locating a system monospace font and blitting its rasterized glyphs into
+//! a titlebar's own pixel buffer - everything `render_titlebar`'s title-
+//! text pass needs, kept separate from the titlebar layout/button logic
+//! that actually calls it.
+
+use super::color::rgb_to_bgra;
+use fontdue::{Font, FontSettings};
+use std::sync::OnceLock;
+
+pub(crate) const FONT_PIXELS: f32 = 13.0;
+pub(crate) const TEXT_LEFT_PADDING: f32 = 8.0;
+
+/// Common monospace font file locations on Linux desktops. Not a full
+/// fontconfig query (no new system dependency for something this small) --
+/// if none of these resolve, titlebars fall back to solid-color-only, same
+/// as before text rendering existed.
+pub(crate) fn find_system_font() -> Option<Font> {
+ static FONT: OnceLock<Option<Font>> = OnceLock::new();
+ FONT.get_or_init(load_any_monospace_font).clone()
+}
+
+fn load_any_monospace_font() -> Option<Font> {
+ let roots = ["/usr/share/fonts", "/usr/local/share/fonts"];
+ let mut home_roots = Vec::new();
+ if let Ok(home) = std::env::var("HOME") {
+ home_roots.push(format!("{home}/.local/share/fonts"));
+ home_roots.push(format!("{home}/.fonts"));
+ }
+ let all_roots = roots.iter().map(|s| s.to_string()).chain(home_roots);
+
+ let mut best: Option<(std::path::PathBuf, u8)> = None;
+ for root in all_roots {
+ find_best_font(std::path::Path::new(&root), &mut best);
+ if matches!(best, Some((_, 0))) {
+ break;
+ }
+ }
+ let (path, _) = best?;
+ let bytes = std::fs::read(&path).ok()?;
+ match Font::from_bytes(bytes, FontSettings::default()) {
+ Ok(f) => {
+ log::info!("wayland titlebar font: {}", path.display());
+ Some(f)
+ }
+ Err(e) => {
+ log::warn!("failed to parse font {}: {e}", path.display());
+ None
+ }
+ }
+}
+
+/// Ranks a font file by how suitable it is for titlebar text: `0` (best) is
+/// a mono-named file with no weight/style marker at all (a plain
+/// "Regular"), `1` is mono but italic/oblique/some other non-regular
+/// weight, `2` is not mono-named at all. Lower is better.
+///
+/// The style-marker list matters as much as the mono check: without it, a
+/// directory listing that happens to turn up `...Mono...-Italic.ttf`
+/// before any regular-weight mono file sorts no worse than one, and gets
+/// picked and stuck with for the process's whole lifetime (`find_system_
+/// font`'s own `OnceLock`). Reported live: this exact case, `/usr/share/
+/// fonts/TTF/JetBrainsMonoNerdFontPropo-Italic.ttf` picked over every
+/// regular-weight JetBrains Mono variant also installed on the same
+/// system, purely because "mono" matched and nothing excluded italic --
+/// every titlebar rendered in italic instead of upright text.
+fn font_rank(path: &std::path::Path) -> u8 {
+ let name = path.to_string_lossy().to_lowercase();
+ let is_mono = name.contains("mono");
+ let is_styled =
+ ["italic", "oblique", "bold", "light", "thin", "black", "medium", "semibold", "extrabold", "condensed"].iter().any(|s| name.contains(s));
+ match (is_mono, is_styled) {
+ (true, false) => 0,
+ (true, true) => 1,
+ (false, _) => 2,
+ }
+}
+
+/// Walks `dir` for the lowest-`font_rank` `.ttf`/`.otf` file, checking
+/// every file rather than stopping at the first mono match - unlike rank
+/// alone, "first found" says nothing about *style*, and the filesystem's
+/// own directory-listing order is not something to trust for that. Only
+/// stops early once a genuine rank-`0` (mono, unstyled) match is found,
+/// since nothing could ever beat that.
+fn find_best_font(dir: &std::path::Path, best: &mut Option<(std::path::PathBuf, u8)>) {
+ if matches!(best, Some((_, 0))) {
+ return;
+ }
+ let Ok(entries) = std::fs::read_dir(dir) else { return };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ find_best_font(&path, best);
+ if matches!(best, Some((_, 0))) {
+ return;
+ }
+ continue;
+ }
+ let is_font = path.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf")).unwrap_or(false);
+ if !is_font {
+ continue;
+ }
+ let rank = font_rank(&path);
+ if best.as_ref().is_none_or(|(_, r)| rank < *r) {
+ *best = Some((path, rank));
+ if rank == 0 {
+ return;
+ }
+ }
+ }
+}
+
+#[allow(clippy::too_many_arguments)]
+pub(crate) fn blit_glyph(
+ buf: &mut [u8],
+ width: usize,
+ height: usize,
+ glyph_x: i32,
+ glyph_y: i32,
+ metrics: &fontdue::Metrics,
+ coverage: &[u8],
+ background: (u8, u8, u8),
+ foreground: (u8, u8, u8),
+) {
+ for row in 0..metrics.height {
+ let y = glyph_y + row as i32;
+ if y < 0 || y as usize >= height {
+ continue;
+ }
+ for col in 0..metrics.width {
+ let x = glyph_x + col as i32;
+ if x < 0 || x as usize >= width {
+ continue;
+ }
+ let cov = coverage[row * metrics.width + col] as f32 / 255.0;
+ if cov <= 0.0 {
+ continue;
+ }
+ let blend = |bg: u8, fg: u8| -> u8 { (bg as f32 * (1.0 - cov) + fg as f32 * cov).round() as u8 };
+ let r = blend(background.0, foreground.0);
+ let g = blend(background.1, foreground.1);
+ let b = blend(background.2, foreground.2);
+ let idx = (y as usize * width + x as usize) * 4;
+ buf[idx..idx + 4].copy_from_slice(&rgb_to_bgra((r, g, b), 255));
+ }
+ }
+}
diff --git a/crates/wayland/src/decoration/shadow.rs b/crates/wayland/src/decoration/shadow.rs
new file mode 100644
index 0000000..df0a0dd
--- /dev/null
+++ b/crates/wayland/src/decoration/shadow.rs
@@ -0,0 +1,186 @@
+//! A window's drop shadow, rasterized as its own BGRA8 bitmap - extent,
+//! sizing, and the corner-aware falloff distance function it needs to read
+//! as rounded next to a window with a real rounded corner, rather than a
+//! plain square-cornered glow sitting incongruously beside one.
+
+/// How far a window's drop shadow extends past its geometry on each side.
+///
+/// `24`, not the original `12`: reported live as srdwm's own shadow
+/// reading as a thin, tight dark line rather than the soft, generously-
+/// sized glow real macOS windows have - doubled, and paired with
+/// `shadow_bitmap`'s own falloff moving from a plain linear ramp to an
+/// eased (`smoothstep`) one, which reads as noticeably softer at the same
+/// pixel budget even without a real blur primitive to work with.
+pub const SHADOW_SIZE: u32 = 24;
+
+/// The shadow's darkest alpha, right at the window's own edge - out of
+/// 255. Deliberately subtle (Nord/GNOME-default territory, not a heavy
+/// drop shadow): this compositor has no blur primitive to soften it with
+/// (see `shadow_bitmap`'s own doc comment), so a strong value would read as
+/// a hard dark ring rather than a shadow. This is the *focused*-window
+/// value - see `shadow_bitmap`'s own `max_alpha` parameter for why an
+/// unfocused window doesn't just reuse it unconditionally.
+pub(crate) const SHADOW_MAX_ALPHA: u8 = 90;
+
+/// `geometry` expanded by [`SHADOW_SIZE`] on every side - the full bounding
+/// box [`shadow_bitmap`] rasterises into, and where the caller positions it
+/// (top-left corner at `(geometry.x - SHADOW_SIZE, geometry.y - SHADOW_SIZE)`).
+pub fn shadow_rect(geometry: srdwm_core::Rect) -> srdwm_core::Rect {
+ let s = SHADOW_SIZE as i32;
+ srdwm_core::Rect::new(geometry.x - s, geometry.y - s, geometry.width + SHADOW_SIZE * 2, geometry.height + SHADOW_SIZE * 2)
+}
+
+/// Renders a window's drop shadow as a BGRA8 bitmap: black at an alpha that
+/// falls off linearly from [`SHADOW_MAX_ALPHA`] right at the window's own
+/// edge to fully transparent [`SHADOW_SIZE`] pixels out. `win_width`/
+/// `win_height` are the window's own footprint (`geometry`, border strips
+/// included if any - whatever the caller already draws as opaque); the
+/// returned bitmap is `shadow_rect`'s size, `SHADOW_SIZE` larger on every
+/// side.
+///
+/// Not a true Gaussian blur - no blur primitive is available without a GPU
+/// shader (the udev backend's `PixmanRenderer` is software-only) or a new
+/// image-processing dependency - so this is a stepless *linear* falloff
+/// using Chebyshev (square-ring) distance from the window's edge rather
+/// than a rounded/radial one, cheap enough to rebuild on every resize (see
+/// the caller for when that is) without a per-pixel sqrt. Reads as "soft
+/// enough" at the sizes a titlebar-height window actually uses, the same
+/// "approximate cutoff over true anti-aliasing" trade-off `corners::round_
+/// top_corners` already makes for corners.
+///
+/// The region directly under the window itself (`dist == 0` below) is left
+/// fully transparent rather than filled - harmless either way since the
+/// window's own border/titlebar/content always draws over it, but skipping
+/// it is one less branch of work for the common case (a window with no
+/// occluders in front of it, so most of the bitmap's interior never
+/// contributes a visible pixel).
+///
+/// `max_alpha` - the shadow's own darkest value, right at the window's
+/// edge - is a parameter rather than always `SHADOW_MAX_ALPHA`, so a
+/// caller can dim an *unfocused* window's shadow the same way `theme.
+/// border.inactive_dim` already dims an unfocused window's border colour
+/// (see `effective_border_color`). Real desktop convention, not invented
+/// here: Hyprland's own `decoration:shadow` config exposes `color` and
+/// `color_inactive` as two separate values specifically for this, common
+/// user configs going as far as a fully transparent `color_inactive` (no
+/// shadow at all once a window loses focus) - confirmed via Hyprland's
+/// own wiki, not assumed. `redraw_decoration_buffer` reuses `theme.
+/// border_inactive_dim` for this rather than adding a second, separately
+/// configurable factor: both are "how much does losing focus fade this
+/// window's own chrome", and this codebase already has a user-tunable
+/// answer to that question.
+pub fn shadow_bitmap(win_width: u32, win_height: u32, radius: u32, max_alpha: u8) -> Vec<u8> {
+ let (win_width, win_height) = (win_width.max(1), win_height.max(1));
+ let width = win_width + SHADOW_SIZE * 2;
+ let height = win_height + SHADOW_SIZE * 2;
+ // Clamped the same way `corners::round_top_corners`/`round_bottom_
+ // corners` clamp their own radius against the buffer they're cutting --
+ // a radius that would eat more than half of either the window's own
+ // width or height isn't geometrically meaningful.
+ let radius = radius.min(win_width / 2).min(win_height / 2);
+ let mut buf = vec![0u8; (width * height * 4) as usize];
+ for y in 0..height {
+ let dy = edge_distance(y, SHADOW_SIZE, win_height);
+ // The widest a row can still possibly contribute a visible pixel:
+ // a corner-quadrant pixel's distance is `sqrt(qx^2 + qy^2) -
+ // radius` where `qy = dy - radius`, and that can still be `<=
+ // SHADOW_SIZE` (this function's own cutoff below) even with
+ // `qx == 0`, i.e. up to `dy == SHADOW_SIZE + 2 * radius` - not
+ // just `SHADOW_SIZE + radius`, which would cut off real corner
+ // pixels a few rows early.
+ if dy > SHADOW_SIZE + 2 * radius {
+ continue;
+ }
+ for x in 0..width {
+ let dx = edge_distance(x, SHADOW_SIZE, win_width);
+ let dist = rounded_edge_distance(dx, dy, radius);
+ if dist == 0 || dist > SHADOW_SIZE {
+ continue;
+ }
+ // Eased (`smoothstep`), not a plain linear ramp - the same
+ // curve `apply_corner_mask`/`fill_button_dot` already use for
+ // their own anti-aliased edges, applied here across the whole
+ // shadow's width instead of a 2px antialiasing band. A linear
+ // falloff reads as a visible ring with a hard-ish inner edge
+ // even when fully transparent at both ends; easing both ends
+ // of the same 0..=1 range softens the transition into and out
+ // of the shadow without needing a real blur primitive.
+ let t = dist as f32 / SHADOW_SIZE as f32;
+ let eased = 1.0 - (t * t * (3.0 - 2.0 * t));
+ let alpha = (max_alpha as f32 * eased).round() as u8;
+ if alpha == 0 {
+ continue;
+ }
+ let i = ((y * width + x) * 4) as usize;
+ // Premultiplied BGRA, but the colour is black (0, 0, 0) - a
+ // premultiplied black pixel is just (0, 0, 0, alpha) at any
+ // alpha, so there's no separate multiply step needed here.
+ buf[i + 3] = alpha;
+ }
+ }
+ buf
+}
+
+/// `edge_distance`'s corner-aware version: `dx`/`dy` are already `edge_
+/// distance`'s own plain per-axis distances past the window's true edge
+/// (`0` on either axis means "not in a corner quadrant at all" - directly
+/// above/below/left/right of the window, or inside it) - this only
+/// changes what happens where *both* are positive, i.e. genuinely outside
+/// the window on both axes at once. Along a flat edge, a rounded rect's
+/// boundary is identical to a square one's (rounding only touches the
+/// corners), so the plain `max(dx, dy)` this replaces was already correct
+/// there and stays correct here too.
+///
+/// Without this, the shadow was a plain square-cornered falloff (`shadow_
+/// bitmap`'s own historical doc comment called this out as a deliberate
+/// Chebyshev-not-radial simplification - reasonable for a soft blur where
+/// nothing else in the frame gives the eye a hard edge to compare against,
+/// but wrong once the window it belongs to has a *visibly* rounded corner
+/// right next to it) - confirmed live via a real screenshot at actual
+/// render resolution: the shadow's own corner cut a hard diagonal well
+/// outside the window's own curve, plainly a different, unrelated shape
+/// sitting right beside it.
+///
+/// The corner-quadrant formula: place the window's true (sharp) corner at
+/// the origin, with the window occupying the quadrant behind it - `dx`/
+/// `dy` are how far past that origin the shadow pixel sits on each axis. A
+/// *rounded* corner's circle sits centred `radius` pixels in from that
+/// origin on both axes, i.e. at `(-radius, -radius)`. Straight-line
+/// distance from the pixel to that centre is `sqrt((dx + radius)^2 + (dy +
+/// radius)^2)`; subtracting `radius` converts "distance to the circle's
+/// centre" into "distance to the circle's own boundary", which is what a
+/// real rounded corner's curve actually traces. (At `dx = dy = 0` - the
+/// old sharp corner's own tip - this correctly comes out positive, not
+/// `0`: the rounded window's boundary has curved away from that point
+/// entirely, so it's already outside the window, not sitting right on its
+/// edge the way a real square corner's tip would be.)
+pub(super) fn rounded_edge_distance(dx: u32, dy: u32, radius: u32) -> u32 {
+ // `radius == 0` (nothing to round - kept byte-identical to the
+ // pre-existing Chebyshev-everywhere behaviour, not just "close
+ // enough": true Euclidean distance to a sharp corner *point* differs
+ // from Chebyshev distance to it even without any rounding, and this
+ // function must not change a square window's own shadow shape) or a
+ // genuine flat-edge point (`dx == 0` xor `dy == 0` - rounding never
+ // touches these, only the four corners) both use plain Chebyshev
+ // distance, unchanged from before this function existed.
+ if radius == 0 || (dx == 0) != (dy == 0) {
+ return dx.max(dy);
+ }
+ let (ex, ey) = (dx as f32 + radius as f32, dy as f32 + radius as f32);
+ let corner_dist = (ex * ex + ey * ey).sqrt() - radius as f32;
+ corner_dist.max(0.0).round() as u32
+}
+
+/// How far outside `[margin, margin + extent)` - the window's own span
+/// along one axis, inside the shadow's `margin`-pixel border on each side
+/// - position `pos` sits, in pixels. `0` anywhere inside that span
+/// (including exactly on its edge).
+fn edge_distance(pos: u32, margin: u32, extent: u32) -> u32 {
+ if pos < margin {
+ margin - pos
+ } else if pos >= margin + extent {
+ pos - (margin + extent) + 1
+ } else {
+ 0
+ }
+}
diff --git a/crates/wayland/src/decoration/tests.rs b/crates/wayland/src/decoration/tests.rs
new file mode 100644
index 0000000..0cc5d45
--- /dev/null
+++ b/crates/wayland/src/decoration/tests.rs
@@ -0,0 +1,756 @@
+use super::*;
+use super::border::*;
+use super::buttons::*;
+use super::color::*;
+use super::corners::*;
+use super::font::*;
+use super::shadow::*;
+use super::titlebar::*;
+
+#[test]
+fn border_strips_surround_geometry_without_overlapping_it() {
+ let geom = srdwm_core::Rect::new(100, 100, 200, 150);
+ let [top, bottom, left, right] = border_strips(geom, 3);
+ // Every strip's own rect must stay entirely outside `geom` - these
+ // are meant to frame the window, not clip into its own titlebar or
+ // content.
+ assert_eq!(top, srdwm_core::Rect::new(97, 97, 206, 3));
+ assert_eq!(bottom, srdwm_core::Rect::new(97, 250, 206, 3));
+ assert_eq!(left, srdwm_core::Rect::new(97, 100, 3, 150));
+ assert_eq!(right, srdwm_core::Rect::new(300, 100, 3, 150));
+}
+
+#[test]
+fn shadow_rect_expands_geometry_by_shadow_size_on_every_side() {
+ let geom = srdwm_core::Rect::new(100, 100, 200, 150);
+ let s = shadow_rect(geom);
+ assert_eq!(s, srdwm_core::Rect::new(100 - SHADOW_SIZE as i32, 100 - SHADOW_SIZE as i32, 200 + SHADOW_SIZE * 2, 150 + SHADOW_SIZE * 2));
+}
+
+#[test]
+fn shadow_bitmap_is_the_expected_size_and_transparent_under_the_window() {
+ let buf = shadow_bitmap(40, 20, 0, SHADOW_MAX_ALPHA);
+ let width = 40 + SHADOW_SIZE * 2;
+ let height = 20 + SHADOW_SIZE * 2;
+ assert_eq!(buf.len(), (width * height * 4) as usize);
+ // Dead center is inside the window's own footprint - must stay
+ // fully transparent, since the window's own content draws over it.
+ let mid = ((height / 2) * width + width / 2) * 4;
+ assert_eq!(buf[mid as usize + 3], 0);
+}
+
+#[test]
+fn shadow_bitmap_is_darkest_right_at_the_window_edge_and_fades_outward() {
+ let buf = shadow_bitmap(40, 20, 0, SHADOW_MAX_ALPHA);
+ let width = (40 + SHADOW_SIZE * 2) as usize;
+ // Walking straight up from the window's horizontal center, from one
+ // pixel above its top edge (row SHADOW_SIZE - 1) out to the shadow's
+ // own outer edge (row 0): alpha must start near SHADOW_MAX_ALPHA and
+ // strictly decrease to 0.
+ let x = width / 2;
+ let mut last_alpha = 255u8;
+ for row in (0..SHADOW_SIZE as usize).rev() {
+ let i = (row * width + x) * 4;
+ let alpha = buf[i + 3];
+ assert!(alpha <= last_alpha, "alpha rose from {last_alpha} to {alpha} moving outward at row {row}");
+ last_alpha = alpha;
+ }
+ assert_eq!(last_alpha, 0, "outermost row must be fully transparent");
+}
+
+#[test]
+fn shadow_falloff_is_eased_not_linear() {
+ // The actual visual change: a plain linear ramp drops the same
+ // amount of alpha every pixel, which reads as a visible ring with
+ // a hard-ish inner edge even though it's transparent at both
+ // ends. An eased (smoothstep) curve drops *less* per pixel near
+ // both ends and *more* in the middle - this is what distinguishes
+ // it from linear at the byte level, not just "still monotonic".
+ let buf = shadow_bitmap(40, 20, 0, SHADOW_MAX_ALPHA);
+ let width = (40 + SHADOW_SIZE * 2) as usize;
+ let x = width / 2;
+ let alpha_at = |dist_from_edge: u32| {
+ let row = SHADOW_SIZE - dist_from_edge;
+ buf[(row as usize * width + x) * 4 + 3] as i32
+ };
+ // Step near the shadow's own inner edge (dist 1 -> 2) versus a
+ // step through the middle (dist SHADOW_SIZE/2 -> SHADOW_SIZE/2+1):
+ // eased must drop less near the edge than a linear ramp would
+ // (linear drops `max_alpha / SHADOW_SIZE` every single step,
+ // uniformly) - the middle step must drop more than that same
+ // uniform linear amount to compensate, since both curves start
+ // and end at the same two values.
+ let linear_step = SHADOW_MAX_ALPHA as i32 / SHADOW_SIZE as i32;
+ let near_edge_drop = alpha_at(1) - alpha_at(2);
+ let middle_drop = alpha_at(SHADOW_SIZE / 2) - alpha_at(SHADOW_SIZE / 2 + 1);
+ assert!(near_edge_drop < linear_step, "near-edge step ({near_edge_drop}) should be gentler than a linear ramp's own uniform step ({linear_step})");
+ assert!(middle_drop > near_edge_drop, "the steepest part of an eased curve should be in the middle ({middle_drop}), not at the edge ({near_edge_drop})");
+}
+
+#[test]
+fn a_lower_max_alpha_produces_a_strictly_fainter_shadow_throughout() {
+ // Locks in the `max_alpha` parameter's actual effect - an
+ // unfocused window's dimmed shadow (see `redraw_decoration_
+ // buffer`'s own call site) must never be darker than the focused
+ // one at any pixel, not just "different".
+ let focused = shadow_bitmap(40, 20, 0, SHADOW_MAX_ALPHA);
+ let dimmed = shadow_bitmap(40, 20, 0, SHADOW_MAX_ALPHA / 2);
+ assert_ne!(focused, dimmed, "sanity: halving max_alpha must actually change the bitmap");
+ for (f, d) in focused.chunks_exact(4).zip(dimmed.chunks_exact(4)) {
+ assert!(d[3] <= f[3], "dimmed alpha {} must never exceed focused alpha {}", d[3], f[3]);
+ }
+}
+
+#[test]
+fn rounded_shadow_corner_tip_is_further_than_the_square_corners_own() {
+ // Regression coverage for the actual live bug: a window's shadow
+ // used to always taper with plain Chebyshev (square) distance, so
+ // its own corner cut a hard diagonal well outside a *rounded*
+ // window's real curve, sitting there as a visibly different,
+ // unrelated shape - confirmed live via a real screenshot at
+ // actual render resolution. At the window's old sharp-corner tip
+ // (dx = dy = 0), the rounded window's boundary has curved away
+ // entirely, so this point must read as *further* from the window
+ // than plain Chebyshev's `0` - not `0`, which would mean "right on
+ // the window's own edge", true only for a genuinely square corner.
+ let radius = 6;
+ assert_eq!(rounded_edge_distance(0, 0, 0), 0, "sanity: radius 0 must match plain Chebyshev distance exactly");
+ let rounded = rounded_edge_distance(0, 0, radius);
+ assert!(rounded > 0, "the old sharp corner's own tip must be outside a rounded window, not sitting right on its edge");
+ // Exact value, worked out by hand: distance from the tip to the
+ // rounding circle's centre (radius, radius) is radius*sqrt(2), so
+ // distance to the circle's own boundary is radius*(sqrt(2) - 1).
+ let expected = (radius as f32 * (2.0f32.sqrt() - 1.0)).round() as u32;
+ assert_eq!(rounded, expected);
+}
+
+#[test]
+fn rounded_edge_distance_matches_plain_chebyshev_along_a_flat_edge() {
+ // Rounding only ever touches the four corners - directly above,
+ // below, left or right of the window (exactly one of dx/dy is 0),
+ // a rounded rect's boundary is identical to a square one's.
+ for (dx, dy) in [(5, 0), (0, 5), (12, 0), (0, 12)] {
+ assert_eq!(rounded_edge_distance(dx, dy, 6), dx.max(dy), "flat-edge point ({dx}, {dy}) must use plain Chebyshev distance, not the corner formula");
+ }
+}
+
+#[test]
+fn shadow_bitmap_corner_is_softer_than_a_square_windows_when_rounded() {
+ let square = shadow_bitmap(40, 40, 0, SHADOW_MAX_ALPHA);
+ let rounded = shadow_bitmap(40, 40, 6, SHADOW_MAX_ALPHA);
+ let width = (40 + SHADOW_SIZE * 2) as usize;
+ // The old sharp corner's own tip: `SHADOW_SIZE` pixels in from the
+ // bitmap's own outer edge on both axes, i.e. exactly at row/column
+ // `SHADOW_SIZE` - where `dx == dy == 0` in `edge_distance`'s own
+ // terms, the window's true corner point. A square window's shadow
+ // is at its darkest possible value there (`dist == 0`, right on
+ // the window's own edge); a rounded window's real boundary has
+ // already curved away from that exact point, so it must read
+ // strictly lighter there (a real positive distance means a
+ // partially-faded, not maximum, alpha) - not identical, which is
+ // what the bug this fixes looked like.
+ let tip = ((SHADOW_SIZE as usize) * width + SHADOW_SIZE as usize) * 4;
+ assert_eq!(square[tip + 3], 0, "sanity: a square window's shadow is fully suppressed right at its own corner");
+ assert!(rounded[tip + 3] > 0, "a rounded window's shadow must actually draw at the old sharp-corner tip, since that point is genuinely outside the rounded boundary");
+}
+
+#[test]
+fn fills_background_when_no_text() {
+ let buf = render_titlebar(40, 20, "", (0x2e, 0x34, 0x40), (0xec, 0xef, 0xf4), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ assert_eq!(buf.len(), 40 * 20 * 4);
+ // Center, not (0,0): the top-left pixel is inside the rounded
+ // corner `round_top_corners` clips away, so it's transparent by
+ // design - see `corners_are_clipped_but_the_middle_is_not` below.
+ let mid = ((20 / 2) * 40 + 40 / 2) * 4;
+ assert_eq!(&buf[mid..mid + 4], &rgb_to_bgra((0x2e, 0x34, 0x40), 255));
+}
+
+#[test]
+fn button_icons_are_drawn_in_the_squares_hit_test_assigns_them() {
+ // Regression test for a bug where every drawn icon was one full
+ // button-width left of where a click on it actually landed: the
+ // visible "X" triggered Maximize, the visible square triggered
+ // Minimize, and the true Close hit-zone (the rightmost
+ // TITLEBAR_HEIGHT-wide band) was blank. `button_box`'s
+ // `right_offset` must put each icon in the same square
+ // `ResizeEdge::hit_test` assigns to it - checked here by picking
+ // the centre pixel of each drawn icon's square and confirming
+ // `hit_test` reports the matching button for that same point.
+ let (width, height) = (300u32, srdwm_core::TITLEBAR_HEIGHT);
+ let bg = (0x2e, 0x34, 0x40);
+ let fg = (0xec, 0xef, 0xf4);
+ let buf = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let frame = srdwm_core::Rect::new(0, 0, width, height);
+ let (width, height) = (width as usize, height as usize);
+
+ let bg_bytes = rgb_to_bgra(bg, 255);
+ let pitch = srdwm_core::BUTTON_PITCH as usize;
+ let margin = srdwm_core::BUTTON_CLUSTER_MARGIN as usize;
+ for (right_offset, expected) in [(margin, srdwm_core::TitlebarHit::Close), (margin + pitch, srdwm_core::TitlebarHit::Maximize), (margin + pitch * 2, srdwm_core::TitlebarHit::Minimize)] {
+ let (x0, y0, x1, y1) = button_box(width, height, right_offset, false, BUTTON_MARGIN);
+ let drawn = (y0..=y1).any(|y| (x0..=x1).any(|x| buf[(y as usize * width + x as usize) * 4..(y as usize * width + x as usize) * 4 + 4] != bg_bytes));
+ assert!(drawn, "expected some drawn icon pixel inside the right_offset={right_offset} square");
+ let cx = (x0 + x1) / 2;
+ let cy = (y0 + y1) / 2;
+ assert_eq!(
+ srdwm_core::ResizeEdge::hit_test(frame, cx, cy, true, 0, srdwm_core::RESIZE_MARGIN, false, None, false),
+ Some(expected),
+ "icon drawn at right_offset={right_offset} does not land in the square hit_test assigns to {expected:?}"
+ );
+ }
+}
+
+#[test]
+fn a_dialog_draws_only_close_and_never_a_coloured_traffic_light() {
+ // Requested directly: "dialog windows shouldn't have maximize/
+ // minimize buttons... don't use traffic lights there ever."
+ let (width, height) = (300u32, srdwm_core::TITLEBAR_HEIGHT);
+ let bg = (0x2e, 0x34, 0x40);
+ let fg = (0xec, 0xef, 0xf4);
+ let bg_bytes = rgb_to_bgra(bg, 255);
+ // `traffic_lights = true` passed in deliberately - `is_dialog`
+ // must override it regardless of what the active theme otherwise
+ // uses everywhere else. `glyph_always = true` so Close's own X is
+ // visible without needing a live hover to check it landed.
+ let dialog = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, true, None, true, true);
+ let normal = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, true, None, true, false);
+ let (w, h) = (width as usize, height as usize);
+
+ // Where a normal (non-dialog) titlebar draws Maximize (offset
+ // `BUTTON_PITCH`) must be untouched background on the dialog --
+ // that button was never drawn there at all, not just hit-test-
+ // unreachable.
+ let margin = srdwm_core::BUTTON_CLUSTER_MARGIN as usize;
+ let maximize_box = button_box(w, h, margin + srdwm_core::BUTTON_PITCH as usize, false, BUTTON_MARGIN);
+ let maximize_drawn_on_normal =
+ (maximize_box.1..=maximize_box.3).any(|y| (maximize_box.0..=maximize_box.2).any(|x| normal[(y as usize * w + x as usize) * 4..(y as usize * w + x as usize) * 4 + 4] != bg_bytes));
+ assert!(maximize_drawn_on_normal, "sanity: a normal titlebar really does draw something at the Maximize slot");
+ let maximize_drawn_on_dialog =
+ (maximize_box.1..=maximize_box.3).any(|y| (maximize_box.0..=maximize_box.2).any(|x| dialog[(y as usize * w + x as usize) * 4..(y as usize * w + x as usize) * 4 + 4] != bg_bytes));
+ assert!(!maximize_drawn_on_dialog, "a dialog must draw nothing at all at the Maximize slot");
+
+ // Close's own box: no coloured fill (a flat, opaque red/near-red
+ // dot, same family `TRAFFIC_LIGHT_CLOSE` produces) anywhere in it
+ // - only the plain-glyph X, drawn in `fg`, may appear.
+ let close_box = button_box(w, h, margin, false, BUTTON_MARGIN);
+ for y in close_box.1..=close_box.3 {
+ for x in close_box.0..=close_box.2 {
+ let i = (y as usize * w + x as usize) * 4;
+ let px = &dialog[i..i + 4];
+ // BGRA - a red-family traffic light has a strongly
+ // dominant red channel (index 2) well above both blue and
+ // green; the plain glyph (`fg`, near-white/grey) and the
+ // untouched background do not.
+ let (b, g, r) = (px[0] as i32, px[1] as i32, px[2] as i32);
+ assert!(!(r > g + 40 && r > b + 40), "close button pixel at ({x},{y}) reads as a coloured traffic light: bgra={px:?}");
+ }
+ }
+}
+
+#[test]
+fn drawing_title_changes_some_pixels_when_font_available() {
+ if find_system_font().is_none() {
+ eprintln!("skipping: no system font found in this sandbox");
+ return;
+ }
+ let bg = (0x2e, 0x34, 0x40);
+ let fg = (0xec, 0xef, 0xf4);
+ let buf = render_titlebar(200, 30, "Terminal", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let bg_bytes = rgb_to_bgra(bg, 255);
+ let changed = buf.chunks_exact(4).any(|px| px != bg_bytes);
+ assert!(changed, "expected at least one pixel to differ from the background once text is drawn");
+}
+
+#[test]
+fn centered_title_starts_further_right_than_left_aligned() {
+ if find_system_font().is_none() {
+ eprintln!("skipping: no system font found in this sandbox");
+ return;
+ }
+ let bg = (0x2e, 0x34, 0x40);
+ let fg = (0xec, 0xef, 0xf4);
+ let (width, height) = (60u32, 30u32);
+ let bg_bytes = rgb_to_bgra(bg, 255);
+ // No buttons at this width (`button_count` gates on
+ // `width >= BUTTON_CLUSTER_MARGIN + BUTTON_PITCH * 3`), so
+ // `text_limit` is the full width and a short title has real room to
+ // actually move when centered - otherwise this could pass even
+ // with centering silently broken.
+ assert!(width < srdwm_core::BUTTON_CLUSTER_MARGIN + srdwm_core::BUTTON_PITCH * 3, "sanity: this fixture must have no button squares eating into text_limit");
+ // Only rows clear of `round_top_corners`' own clipping (confined to
+ // `CORNER_RADIUS` rows from the top - see its doc comment) --
+ // otherwise a clipped-to-transparent corner pixel (a different
+ // byte pattern than `bg_bytes`) registers as "ink" at column 0
+ // regardless of where the text actually starts.
+ let scan_rows = CORNER_RADIUS as usize..height as usize;
+ let leftmost_ink_column = |buf: &[u8]| -> Option<usize> {
+ (0..width as usize).find(|&x| scan_rows.clone().any(|y| buf[(y * width as usize + x) * 4..(y * width as usize + x) * 4 + 4] != bg_bytes))
+ };
+ let left = render_titlebar(width, height, "Hi", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let centered = render_titlebar(width, height, "Hi", bg, fg, true, CORNER_RADIUS, 0, true, None, true, false, false, None, true, false);
+ let left_start = leftmost_ink_column(&left).expect("left-aligned title must draw some ink");
+ let centered_start = leftmost_ink_column(&centered).expect("centered title must draw some ink");
+ assert!(centered_start > left_start, "a short title centered in a wide titlebar must start well to the right of the left-aligned version (left starts at {left_start}, centered at {centered_start})");
+}
+
+#[test]
+fn centered_title_ignores_the_button_reservation_and_centers_on_the_whole_width() {
+ if find_system_font().is_none() {
+ eprintln!("skipping: no system font found in this sandbox");
+ return;
+ }
+ let bg = (0x2e, 0x34, 0x40);
+ let fg = (0xec, 0xef, 0xf4);
+ // Wide enough for the 3-button reservation (`width >=
+ // BUTTON_CLUSTER_MARGIN + BUTTON_PITCH * 3`) on the left - this is
+ // exactly the case that used to center in the narrower `text_start
+ // ..text_limit` span left over after the buttons, landing off the
+ // window's true center instead of at `width / 2`.
+ let (width, height) = (300u32, 30u32);
+ assert!(width >= srdwm_core::BUTTON_CLUSTER_MARGIN + srdwm_core::BUTTON_PITCH * 3, "sanity: this fixture must have button squares eating into text_start");
+ let bg_bytes = rgb_to_bgra(bg, 255);
+ let scan_rows = CORNER_RADIUS as usize..height as usize;
+ // Scan only from `text_start` (the button reservation's own right
+ // edge) onward - the centering formula never places text left of
+ // that regardless, and scanning from column 0 would also pick up
+ // the button dots themselves (real, different-colour ink), not
+ // just the title text this is actually trying to measure.
+ let button_reservation = srdwm_core::BUTTON_CLUSTER_MARGIN as usize + srdwm_core::BUTTON_PITCH as usize * 3;
+ let ink_columns = |buf: &[u8]| -> Vec<usize> {
+ (button_reservation..width as usize).filter(|&x| scan_rows.clone().any(|y| buf[(y * width as usize + x) * 4..(y * width as usize + x) * 4 + 4] != bg_bytes)).collect()
+ };
+ let buf = render_titlebar(width, height, "Hi", bg, fg, true, CORNER_RADIUS, 0, true, None, true, true, false, None, true, false);
+ let columns = ink_columns(&buf);
+ let (first, last) = (*columns.first().expect("centered title must draw some ink"), *columns.last().unwrap());
+ let midpoint = (first + last) as f32 / 2.0;
+ let old_buggy_midpoint = 195.0; // center of the 90..300 span the button reservation used to leave
+ assert!((midpoint - width as f32 / 2.0).abs() < 5.0, "title midpoint {midpoint} should land within a few px of the true window center ({}), not the button-adjusted span's own center ({old_buggy_midpoint})", width as f32 / 2.0);
+}
+
+#[test]
+fn empty_title_leaves_buffer_all_background_outside_the_rounded_corners() {
+ let bg = (0x10, 0x20, 0x30);
+ let (width, height) = (50, 24);
+ let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let bg_bytes = rgb_to_bgra(bg, 255);
+ for (i, px) in buf.chunks_exact(4).enumerate() {
+ let (x, y) = (i % width as usize, i / width as usize);
+ let in_top_left = x < CORNER_RADIUS as usize && y < CORNER_RADIUS as usize;
+ let in_top_right = x >= width as usize - CORNER_RADIUS as usize && y < CORNER_RADIUS as usize;
+ if !in_top_left && !in_top_right {
+ assert_eq!(px, bg_bytes, "unexpected non-background pixel at ({x}, {y})");
+ }
+ }
+}
+
+#[test]
+fn corners_are_clipped_but_the_middle_is_not() {
+ let bg = (0x10, 0x20, 0x30);
+ let (width, height) = (50, 24);
+ let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
+ // The very corner pixel is well outside the quarter-circle at any
+ // sane radius - fully clipped.
+ assert_eq!(alpha_at(0, 0), 0, "top-left corner pixel should be transparent");
+ assert_eq!(alpha_at(width as usize - 1, 0), 0, "top-right corner pixel should be transparent");
+ // Bottom corners are deliberately left square (see the function's
+ // doc comment: the titlebar's bottom edge meets client content,
+ // which can't be clipped the same way).
+ assert_eq!(alpha_at(0, height as usize - 1), 255, "bottom-left must stay square");
+ assert_eq!(alpha_at(width as usize - 1, height as usize - 1), 255, "bottom-right must stay square");
+ // Centre is nowhere near either corner circle - untouched.
+ assert_eq!(alpha_at(width as usize / 2, height as usize / 2), 255);
+}
+
+#[test]
+fn clipped_corner_pixels_are_fully_premultiplied_zero_not_just_alpha() {
+ // Regression test: `round_top_corners` used to zero only the alpha
+ // byte of a clipped pixel, leaving the opaque background RGB behind
+ // it untouched. This buffer is `Fourcc::Argb8888`, which both
+ // Wayland/`wl_shm` and Pixman treat as premultiplied - Pixman's own
+ // `OVER` compositing (`result = src + dst * (1 - src_alpha)`) does
+ // not treat `alpha=0, rgb=<something>` as "contributes nothing": it
+ // adds that stale, un-premultiplied `rgb` straight through, so the
+ // "clipped" corner still rendered fully opaque and every window's
+ // top corners read as square regardless of a nonzero radius --
+ // confirmed live, pixel-by-pixel, zero transparency anywhere in a
+ // real window's corner. A genuinely transparent premultiplied pixel
+ // is `(0, 0, 0, 0)` in every channel, not just alpha.
+ let bg = (0x10, 0x20, 0x30);
+ let (width, height) = (50, 24);
+ let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let px_at = |x: usize, y: usize| &buf[(y * width as usize + x) * 4..(y * width as usize + x) * 4 + 4];
+ assert_eq!(px_at(0, 0), [0, 0, 0, 0], "top-left corner pixel must be fully zeroed (premultiplied transparent), not just alpha");
+ assert_eq!(px_at(width as usize - 1, 0), [0, 0, 0, 0], "top-right corner pixel must be fully zeroed (premultiplied transparent), not just alpha");
+}
+
+#[test]
+fn round_corners_false_leaves_the_top_corners_square() {
+ let bg = (0x10, 0x20, 0x30);
+ let (width, height) = (50, 24);
+ let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), false, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
+ assert_eq!(alpha_at(0, 0), 255, "top-left corner should stay square when round_corners is false");
+ assert_eq!(alpha_at(width as usize - 1, 0), 255, "top-right corner should stay square when round_corners is false");
+}
+
+#[test]
+fn brighten_lightens_every_channel_including_a_saturated_one() {
+ let (r, g, b) = brighten((0x28, 0xc8, 0x00));
+ assert!(r > 0x28, "already-bright channel must still move toward white, not stay put");
+ assert!(g > 0xc8);
+ assert!(b > 0x00, "a fully-unsaturated (0) channel must still brighten, not stay clamped at 0");
+}
+
+#[test]
+fn hovering_the_close_button_brightens_only_that_dot() {
+ // The actual feature this is for: hovering one button must change
+ // *that* button's colour and leave the other two exactly as they
+ // were - not brighten all three, and not brighten the wrong one.
+ let (width, height) = (200u32, srdwm_core::TITLEBAR_HEIGHT);
+ let bg = (0x2e, 0x34, 0x40);
+ let fg = (0xec, 0xef, 0xf4);
+ let plain = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let close_hovered = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, Some((srdwm_core::TitlebarHit::Close, 255)), false, false, false, None, true, false);
+ let frame = srdwm_core::Rect::new(0, 0, width, height);
+ let (w, h) = (width as usize, height as usize);
+ let margin = srdwm_core::BUTTON_CLUSTER_MARGIN as usize;
+ let close_box = button_box(w, h, margin, false, BUTTON_MARGIN);
+ let minimize_box = button_box(w, h, margin + srdwm_core::BUTTON_PITCH as usize * 2, false, BUTTON_MARGIN);
+ let center_px = |buf: &[u8], (x0, y0, x1, y1): (i32, i32, i32, i32)| {
+ let (cx, cy) = ((x0 + x1) / 2, (y0 + y1) / 2);
+ let i = (cy as usize * w + cx as usize) * 4;
+ buf[i..i + 4].to_vec()
+ };
+ assert_ne!(center_px(&plain, close_box), center_px(&close_hovered, close_box), "hovering close must actually change its own dot's colour");
+ assert_eq!(center_px(&plain, minimize_box), center_px(&close_hovered, minimize_box), "hovering close must not also change the minimize dot");
+ // Sanity: hit_test must actually route a click at this same centre
+ // point to Close, or this test would be checking a hover state
+ // that a real pointer could never reach in the first place.
+ let (cx, cy) = ((close_box.0 + close_box.2) / 2, (close_box.1 + close_box.3) / 2);
+ assert_eq!(srdwm_core::ResizeEdge::hit_test(frame, cx, cy, true, 0, srdwm_core::RESIZE_MARGIN, false, None, false), Some(srdwm_core::TitlebarHit::Close));
+}
+
+#[test]
+fn button_dot_has_a_glossy_highlight_toward_the_upper_left_and_shadow_toward_the_lower_right() {
+ // The actual visual change requested: a flat-filled dot read as
+ // noticeably flatter than real macOS's own traffic lights (see
+ // `glossy_shade`'s own doc comment, referenced against a real
+ // screenshot). This locks in the shape of that gradient, not just
+ // that pixels differ from the center - upper-left must be
+ // brighter than center, lower-right must be darker, matching a
+ // light source up-and-to-the-left.
+ let (width, height) = (200u32, srdwm_core::TITLEBAR_HEIGHT);
+ let bg = (0x2e, 0x34, 0x40);
+ let fg = (0xec, 0xef, 0xf4);
+ let buf = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let (w, h) = (width as usize, height as usize);
+ let close_box = button_box(w, h, srdwm_core::BUTTON_CLUSTER_MARGIN as usize, false, BUTTON_MARGIN);
+ let (cx, cy) = ((close_box.0 + close_box.2) / 2, (close_box.1 + close_box.3) / 2);
+ let radius = ((close_box.2 - close_box.0).min(close_box.3 - close_box.1) as f32 / 2.0) * 0.6;
+ let px = |x: i32, y: i32| -> u32 {
+ let i = (y as usize * w + x as usize) * 4;
+ // BGRA - sum the colour channels, ignore alpha (always 255
+ // here), so "brighter"/"darker" reads as a plain luma proxy.
+ buf[i] as u32 + buf[i + 1] as u32 + buf[i + 2] as u32
+ };
+ let center = px(cx, cy);
+ let highlight = px(cx - radius.round() as i32, cy - radius.round() as i32);
+ let shadow = px(cx + radius.round() as i32, cy + radius.round() as i32);
+ assert!(highlight > center, "upper-left of the dot ({highlight}) should be brighter than its centre ({center})");
+ assert!(shadow < center, "lower-right of the dot ({shadow}) should be darker than its centre ({center})");
+}
+
+#[test]
+fn border_top_rounds_its_own_top_corners_to_match_the_titlebar() {
+ // Regression coverage for the "not all window borders are rounded"
+ // report: a bordered window's titlebar used to render with
+ // `round_corners = false` specifically to avoid clashing with this
+ // strip's square corners. Now that this strip rounds too, that
+ // workaround is gone (`render_titlebar` is always called with
+ // `true`) - this just confirms the strip actually does what that
+ // change now depends on.
+ let color = (0x40, 0x50, 0x60);
+ let (width, thickness) = (60, 2);
+ let buf = render_border_top(width, thickness, color, CORNER_RADIUS);
+ let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
+ assert_eq!(alpha_at(0, 0), 0, "top-left corner pixel should be clipped");
+ assert_eq!(alpha_at(width as usize - 1, 0), 0, "top-right corner pixel should be clipped");
+ // The strip is only `thickness` pixels tall, but its curve is the
+ // titlebar's own radius continued outward (`radius + thickness`,
+ // see `render_border_top`) - so the horizontal centre, well clear
+ // of either corner's cut zone, must stay opaque regardless of how
+ // much taller than the strip that combined radius is.
+ assert_eq!(alpha_at(width as usize / 2, thickness as usize - 1), 255, "centre of the strip must stay opaque");
+}
+
+#[test]
+fn border_top_and_titlebar_corners_meet_without_a_seam() {
+ // Regression coverage for a *different* bug than the one this test
+ // used to check (see git history for the old
+ // `border_top_corner_curve_matches_the_titlebars_larger_radius`):
+ // an earlier version of this pair rounded the border strip to
+ // `radius + thickness` while the titlebar rounded to plain
+ // `radius` - two circles sharing a centre but with *different*
+ // radii, which do not meet smoothly at any boundary between them.
+ // Confirmed live, screenshotted at actual render resolution: a
+ // hard stepped notch right where a decorated window's border met
+ // its titlebar, not a continuous curve. Both now draw their own
+ // slice of the exact same circle (`round_top_corners`'s own doc
+ // comment has the full geometry) - this checks that promise
+ // directly, by rendering both real bitmaps with the same
+ // parameters a live window actually uses and comparing the alpha
+ // at the border's last row against the titlebar's first row,
+ // immediately below it.
+ let color = (0x40, 0x50, 0x60);
+ let (width, thickness, radius) = (60, 4, 6);
+ let border = render_border_top(width, thickness, color, radius);
+ let titlebar = render_titlebar(width, 24, "", color, (0xff, 0xff, 0xff), true, radius, thickness, true, None, false, false, false, None, true, false);
+ let border_alpha_at = |x: usize| border[((thickness as usize - 1) * width as usize + x) * 4 + 3];
+ let titlebar_alpha_at = |x: usize| titlebar[x * 4 + 3];
+ // Every column across the curve's actual reach, not just one
+ // sample point - a seam bug shows up as a jump at some columns
+ // and not others (the exact shape of the mismatch between two
+ // differently-sized circles), so checking only the corner pixel
+ // or only the centre could miss it entirely, the same way the
+ // original bug slipped past the test above it for months. Worked
+ // out by hand (see this fix's own commit) what the two designs
+ // actually produce at every column for radius=6/thickness=4: the
+ // old (`radius + thickness`-for-the-border) design jumped by as
+ // much as 187 out of 255, at *every* column past the first two;
+ // this design jumps by at most 70, confined to the two columns
+ // nearest the exact tip - an inherent limit of splitting one
+ // steep curve across two separately-rasterised bitmaps a single
+ // row apart, not a bug to chase further. `< 90` catches any
+ // regression back toward the old behaviour without demanding
+ // more precision than two 1px-apart raster buffers can give.
+ for x in 0..radius as usize + 2 {
+ let (b, t) = (border_alpha_at(x), titlebar_alpha_at(x));
+ let jump = (b as i32 - t as i32).abs();
+ assert!(jump < 90, "column {x}: border's last row (alpha={b}) and titlebar's first row (alpha={t}) must be close, not a sharp seam (jump={jump})");
+ }
+ // Past the tip's unavoidable steepness (columns 0-1 above), the
+ // curve should be genuinely, near-exactly continuous - both
+ // rows fully opaque by then for this radius/thickness, not just
+ // "close enough".
+ for x in 2..radius as usize + 2 {
+ let (b, t) = (border_alpha_at(x), titlebar_alpha_at(x));
+ let jump = (b as i32 - t as i32).abs();
+ assert!(jump <= 2, "column {x}: past the corner tip the seam should be essentially exact, not just under the looser tip tolerance (jump={jump})");
+ }
+}
+
+#[test]
+fn border_top_visible_rows_decorated_shows_the_whole_taller_buffer() {
+ let (row0, rows, shift) = border_top_visible_rows(true, 4, 11);
+ assert_eq!((row0, rows, shift), (0, 11, 0), "decorated: full max(border_width, radius) buffer, unshifted");
+}
+
+#[test]
+fn border_top_visible_rows_undecorated_crops_to_just_the_nominal_thickness() {
+ // The actual regression this exists for: reported live as a
+ // border-coloured wedge cut into a real undecorated Firefox
+ // window's top-left corner, confirmed via a real screenshot to be
+ // neither Firefox's own rendering nor the content-mask feature --
+ // this buffer's own titlebar-band-only extra rows, painted
+ // straight onto real content instead, were the only remaining
+ // source.
+ let (row0, rows, shift) = border_top_visible_rows(false, 4, 11);
+ assert_eq!((row0, rows, shift), (0, 4, 0), "undecorated: cropped to exactly border_width rows, still starting at row 0 (this strip grows downward)");
+}
+
+#[test]
+fn border_top_visible_rows_radius_no_bigger_than_border_is_a_no_op_either_way() {
+ // When the buffer was never grown taller than `border_width` in
+ // the first place (radius <= border_width), decorated and
+ // undecorated must agree - there are no "extra" rows to disagree
+ // about.
+ assert_eq!(border_top_visible_rows(true, 6, 4), border_top_visible_rows(false, 6, 4));
+}
+
+#[test]
+fn border_bottom_visible_rows_decorated_shows_the_whole_taller_buffer_shifted_up() {
+ let (row0, rows, shift) = border_bottom_visible_rows(true, 4, 11);
+ assert_eq!((row0, rows, shift), (0, 11, 7), "decorated: full buffer, shifted up by extra = max(4,11) - 4 = 7");
+}
+
+#[test]
+fn border_bottom_visible_rows_undecorated_crops_to_the_buffers_last_rows_unshifted() {
+ // Same real bug as the top strip, confirmed on the same Firefox
+ // window's bottom-left corner via a real screenshot - this strip
+ // grows *upward* into content instead of downward, so the safe
+ // rows are the buffer's *last* `border_width` of them (starting at
+ // `row0 = extra`), not its first, and no position shift is needed
+ // once the extra rows themselves are never drawn.
+ let (row0, rows, shift) = border_bottom_visible_rows(false, 4, 11);
+ assert_eq!((row0, rows, shift), (7, 4, 0), "undecorated: last border_width rows only, starting past the 7-row extra, unshifted");
+}
+
+#[test]
+fn border_bottom_visible_rows_radius_no_bigger_than_border_is_a_no_op_either_way() {
+ assert_eq!(border_bottom_visible_rows(true, 6, 4), border_bottom_visible_rows(false, 6, 4));
+}
+
+#[test]
+fn border_top_extra_rows_are_transparent_outside_the_corners() {
+ // `render_border_bottom`'s mirror - see
+ // `border_bottom_extra_rows_are_transparent_outside_the_corners`'s
+ // doc comment for the full story (this is the same real,
+ // live-confirmed bug, the top-strip half of the pair).
+ let color = (0x40, 0x50, 0x60);
+ let (width, thickness, radius) = (60, 2, 6);
+ let buf = render_border_top(width, thickness, color, radius);
+ let height = (thickness as usize).max(radius as usize);
+ assert_eq!(buf.len(), width as usize * height * 4, "buffer must actually be the taller max(thickness, radius) height");
+ let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
+ // The strip's real thickness sits at the *top* here (rows grow
+ // downward into content, the mirror of the bottom strip growing
+ // upward) - rows 0..thickness must still be fully opaque in the
+ // middle, exactly as before this fix.
+ for y in 0..thickness as usize {
+ assert_eq!(alpha_at(width as usize / 2, y), 255, "row {y}: within the strip's real thickness, the middle column must stay opaque");
+ }
+ for y in thickness as usize..height {
+ assert_eq!(alpha_at(width as usize / 2, y), 0, "row {y}: middle column of an extra row must be transparent, not solid border colour");
+ }
+}
+
+#[test]
+fn border_top_curve_actually_closes_within_the_side_strips_own_width() {
+ // The actual, directly-observable bug this whole fix is for:
+ // confirmed live via pixel-level inspection of a real screenshot
+ // (not just reasoned about) that the horizontal top border segment
+ // only became opaque some ~20 columns in from the corner while the
+ // *flat, curve-blind* left/right strip only ever covers its own
+ // `border_width` columns - so with `radius` meaningfully larger
+ // than `border_width`, there was a real gap of bare background
+ // between them, at exactly the column range a real vertical border
+ // strip occupies. This checks that gap is actually closed: by this
+ // buffer's own last row (where the curve should have fully
+ // resolved to flat, for a `radius` that fits within the strip's
+ // half-width), the column right at the edge of where a
+ // `border_width`-wide side strip would sit must already be opaque.
+ let color = (0x40, 0x50, 0x60);
+ let (width, thickness, border_width, radius) = (60u32, 2u32, 3u32, 6u32);
+ let buf = render_border_top(width, thickness, color, radius);
+ let height = (thickness as usize).max(radius as usize);
+ let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
+ assert_eq!(alpha_at(border_width as usize - 1, height - 1), 255, "the side strip's own rightmost column must be fully covered by the curve at the buffer's last row, not left as a gap");
+}
+
+#[test]
+fn border_bottom_rounds_its_own_bottom_corners() {
+ // `thickness` (2) < `CORNER_RADIUS` (6) here, same as the live
+ // theme defaults (border width 4, radius 6) - so the buffer is
+ // taller than `thickness`, and the true tip (fully clipped corner)
+ // sits at the buffer's own *last* row, not `thickness - 1` (see
+ // `render_border_bottom`'s doc comment for why the buffer grows at
+ // all).
+ let color = (0x40, 0x50, 0x60);
+ let (width, thickness) = (60, 2);
+ let buf = render_border_bottom(width, thickness, color, CORNER_RADIUS);
+ let height = (thickness as usize).max(CORNER_RADIUS as usize);
+ assert_eq!(buf.len(), width as usize * height * 4, "buffer must actually be the taller max(thickness, radius) height");
+ let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
+ let tip_row = height - 1;
+ assert_eq!(alpha_at(0, tip_row), 0, "bottom-left corner pixel should be clipped");
+ assert_eq!(alpha_at(width as usize - 1, tip_row), 0, "bottom-right corner pixel should be clipped");
+ assert_eq!(alpha_at(width as usize / 2, tip_row), 255, "centre of the strip must stay opaque even at the tip row");
+}
+
+#[test]
+fn border_bottom_extra_rows_are_transparent_outside_the_corners() {
+ // Regression coverage for the real bug this pair of functions was
+ // fixed for: with `radius > thickness`, the strip's own curve
+ // didn't reach far enough to meet the (curve-blind, flat) side
+ // strips, leaving a wedge of bare background between them --
+ // confirmed live via pixel-level inspection of a real screenshot,
+ // not just reasoned about. The fix grows the buffer to
+ // `max(thickness, radius)` so the curve has room to fully resolve,
+ // but the *extra* rows (above the original `thickness`, since this
+ // strip grows upward into content - see `render_border_bottom`'s
+ // doc comment) must stay transparent in the middle (non-corner)
+ // columns, or they'd paint a solid border-coloured bar across
+ // whatever the content actually owns there.
+ let color = (0x40, 0x50, 0x60);
+ let (width, thickness, radius) = (60, 2, 6);
+ let buf = render_border_bottom(width, thickness, color, radius);
+ let height = (thickness as usize).max(radius as usize);
+ let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
+ for y in 0..height - thickness as usize {
+ assert_eq!(alpha_at(width as usize / 2, y), 0, "row {y}: middle column of an extra row must be transparent, not solid border colour");
+ }
+ // The original `thickness` rows (now at the *bottom* of the taller
+ // buffer) must still be the strip's real, fully opaque body in the
+ // middle - this fix must not have eaten into the strip's own
+ // legitimate thickness.
+ for y in height - thickness as usize..height {
+ assert_eq!(alpha_at(width as usize / 2, y), 255, "row {y}: within the strip's real thickness, the middle column must stay opaque");
+ }
+}
+
+#[test]
+fn context_menu_is_one_row_tall_per_item() {
+ let items = [("Minimize", false), ("Maximize", false), ("Always on Top", false), ("Close", false)];
+ let buf = render_context_menu(160, 28, &items, (0x2e, 0x34, 0x40), (0xff, 0xff, 0xff), (0x4c, 0x56, 0x6a), (0x10, 0x10, 0x10));
+ assert_eq!(buf.len(), 160 * (28 * 4) * 4);
+}
+
+#[test]
+fn context_menu_highlighted_row_has_a_different_background_than_the_rest() {
+ let items = [("Minimize", false), ("Close", true)];
+ let bg = (0x2e, 0x34, 0x40);
+ let highlight = (0x4c, 0x56, 0x6a);
+ let buf = render_context_menu(160, 28, &items, bg, (0xff, 0xff, 0xff), highlight, (0x10, 0x10, 0x10));
+ let width = 160usize;
+ // Sample a background pixel from each row, away from the text/border.
+ let px_at = |x: usize, y: usize| -> [u8; 3] {
+ let i = (y * width + x) * 4;
+ [buf[i + 2], buf[i + 1], buf[i]] // BGRA -> RGB
+ };
+ assert_eq!(px_at(100, 5), [bg.0, bg.1, bg.2], "row 0 (not highlighted) should use bg");
+ assert_eq!(px_at(100, 33), [highlight.0, highlight.1, highlight.2], "row 1 (highlighted) should use highlight_bg");
+}
+
+#[test]
+fn context_menu_border_is_opaque_at_every_edge() {
+ let items = [("Close", false)];
+ let buf = render_context_menu(100, 28, &items, (0, 0, 0), (0xff, 0xff, 0xff), (0, 0, 0), (0x99, 0x99, 0x99));
+ let alpha_at = |x: usize, y: usize| buf[(y * 100 + x) * 4 + 3];
+ assert_eq!(alpha_at(0, 0), 255);
+ assert_eq!(alpha_at(99, 0), 255);
+ assert_eq!(alpha_at(0, 27), 255);
+ assert_eq!(alpha_at(99, 27), 255);
+}
+
+#[test]
+fn snap_flyout_is_sized_for_a_full_grid_of_labels() {
+ let labels = ["Left Half", "Right Half", "Top Left", "Top Right", "Bottom Left", "Bottom Right"];
+ let buf = render_snap_flyout(3, 90, 60, &labels, (0x2e, 0x34, 0x40), (0xff, 0xff, 0xff), (0x10, 0x10, 0x10));
+ // 3 columns x 2 rows (6 labels / 3 columns, rounded up).
+ assert_eq!(buf.len(), (90 * 3) * (60 * 2) * 4);
+}
+
+#[test]
+fn snap_flyout_border_is_opaque_at_every_outer_edge() {
+ let labels = ["A", "B", "C", "D", "E", "F"];
+ let (cell_w, cell_h) = (90, 60);
+ let buf = render_snap_flyout(3, cell_w, cell_h, &labels, (0, 0, 0), (0xff, 0xff, 0xff), (0x99, 0x99, 0x99));
+ let (width, height) = (cell_w * 3, cell_h * 2);
+ let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
+ assert_eq!(alpha_at(0, 0), 255);
+ assert_eq!(alpha_at(width as usize - 1, 0), 255);
+ assert_eq!(alpha_at(0, height as usize - 1), 255);
+ assert_eq!(alpha_at(width as usize - 1, height as usize - 1), 255);
+}
+
+#[test]
+fn snap_flyout_has_an_internal_grid_line_between_columns() {
+ let labels = ["A", "B", "C", "D", "E", "F"];
+ let (cell_w, cell_h) = (90, 60);
+ let buf = render_snap_flyout(3, cell_w, cell_h, &labels, (0, 0, 0), (0xff, 0xff, 0xff), (0x99, 0x99, 0x99));
+ let width = cell_w * 3;
+ // The boundary between column 0 and column 1, away from the outer border.
+ let idx = (30 * width as usize + cell_w as usize) * 4;
+ assert_eq!(buf[idx + 3], 255, "column boundary must be drawn, not just the outer border");
+}
diff --git a/crates/wayland/src/decoration/titlebar.rs b/crates/wayland/src/decoration/titlebar.rs
new file mode 100644
index 0000000..49f9de5
--- /dev/null
+++ b/crates/wayland/src/decoration/titlebar.rs
@@ -0,0 +1,295 @@
+//! Laying out and rasterizing the whole titlebar band: background, title
+//! text, and the button cluster (which one goes where, which side, how
+//! many). The buttons' own dots/glyphs are `buttons.rs`'s job; the corner
+//! cut at the end is `corners.rs`'s.
+
+use super::buttons::{
+ draw_close_glyph, draw_maximize_glyph, draw_minimize_glyph, draw_zoom_glyph, fill_button_dot, BUTTON_MARGIN, BUTTON_MARGIN_LEFT, TRAFFIC_LIGHT_CLOSE,
+ TRAFFIC_LIGHT_INACTIVE, TRAFFIC_LIGHT_MAXIMIZE, TRAFFIC_LIGHT_MINIMIZE,
+};
+use super::color::{brighten, darken, rgb_to_bgra};
+use super::corners::round_top_corners;
+use super::font::{blit_glyph, find_system_font, FONT_PIXELS, TEXT_LEFT_PADDING};
+
+/// Renders a `width x height` BGRA8 buffer: filled with `background`, with
+/// `title` drawn left-aligned in `foreground` (best-effort glyph layout --
+/// no text shaping/kerning, adequate for the ASCII-heavy titles window
+/// managers actually display). Returns `None` (caller keeps the previous
+/// solid-color-only look) only if no usable font was found on this system.
+///
+/// `round_corners` should be `false` only for a window whose border strips
+/// are rendered as plain square-cornered fills with no matching rounded
+/// treatment of their own. `border::render_border_top` gives the border's
+/// top strip the same rounded-corner cut (see its own doc comment for how
+/// the two stay visually continuous), so a normal bordered window should
+/// pass `true` here same as a borderless one now - reported live as most
+/// windows (anything with the default border) looking inconsistently
+/// square next to the few borderless ones that were rounded.
+///
+/// `border_width` shifts the corner circle's centre by that many rows (see
+/// `corners::round_top_corners`'s own doc comment): a titlebar with a
+/// border strip sitting above it starts `border_width` rows *into* the
+/// shared circle, not at its top, so it needs the same shift subtracted to
+/// draw its own correct slice of that one circle rather than a second,
+/// uncoordinated one. Pass `0` for an undecorated/borderless window's
+/// titlebar (there is none in practice - an undecorated window has no
+/// titlebar at all - but `0` is also the correct, harmless value if
+/// `round_corners` handling ever changes to allow it).
+#[allow(clippy::too_many_arguments)]
+pub fn render_titlebar(
+ width: u32,
+ height: u32,
+ title: &str,
+ background: (u8, u8, u8),
+ foreground: (u8, u8, u8),
+ round_corners: bool,
+ radius: u32,
+ border_width: u32,
+ focused: bool,
+ // `(button, glyph alpha 0..=255)` - the alpha is the eased hover-
+ // reveal animation's own current progress (see `tick_hover_glyph_
+ // animation`), already discretized by the caller so this stays a
+ // plain data-in function with no `Instant`/timing concept of its own.
+ hovered: Option<(srdwm_core::TitlebarHit, u8)>,
+ centered: bool,
+ buttons_left: bool,
+ // Modern GNOME/Adwaita mode (see `ThemeConfig::button_glyph_always`'s
+ // own doc comment): every glyph drawn at full opacity always, `hovered`
+ // only still used for the background-circle brighten below, not glyph
+ // visibility.
+ glyph_always: bool,
+ // `ThemeConfig::button_order`'s resolved value - see `ButtonOrder`'s
+ // own doc comment. Must stay in agreement with whatever `ResizeEdge::
+ // hit_test` was called with for the same window, the same "renders on
+ // one side, hit-tests on the other" trap `buttons_left` itself already
+ // has to avoid.
+ button_order: Option<srdwm_core::ButtonOrder>,
+ // `ThemeConfig::traffic_light_buttons`'s resolved value - see its own
+ // doc comment for what each mode actually draws differently.
+ traffic_lights: bool,
+ // `Window::is_dialog`'s resolved value - see its own doc comment. Only
+ // Close is ever drawn for a dialog, and never as a coloured traffic
+ // light regardless of `traffic_lights` above (forced off below):
+ // requested directly ("dialog windows shouldn't have maximize/minimize
+ // buttons... don't use traffic lights there ever"). Must stay in exact
+ // agreement with `ResizeEdge::hit_test`'s own `is_dialog` parameter,
+ // the same "renders on one side, hit-tests on the other" trap every
+ // other button-geometry value here already has to avoid.
+ is_dialog: bool,
+) -> Vec<u8> {
+ let (width, height) = (width.max(1) as usize, height.max(1) as usize);
+ // Forced off, not just defaulted - a dialog never gets coloured
+ // traffic lights even when the active theme otherwise uses them
+ // everywhere else.
+ let traffic_lights = traffic_lights && !is_dialog;
+ let bg = rgb_to_bgra(background, 255);
+ let mut buf = vec![0u8; width * height * 4];
+ for px in buf.chunks_exact_mut(4) {
+ px.copy_from_slice(&bg);
+ }
+
+ // Reserve the button squares (whichever side they're actually on)
+ // before laying out text, so a long title elides under them the same
+ // way it would under real window furniture rather than drawing on top
+ // of it. `text_start`/`text_limit` bound the span text is allowed to
+ // occupy - both edges when `buttons_left` (buttons eat into the left,
+ // not the right), only the far edge otherwise.
+ let pitch = srdwm_core::BUTTON_PITCH as usize;
+ let cluster_margin = srdwm_core::BUTTON_CLUSTER_MARGIN as usize;
+ // A dialog only ever gets one button (Close) - see this function's own
+ // `is_dialog` doc comment.
+ let wanted_buttons = if is_dialog { 1 } else { 3 };
+ let button_count = if width >= cluster_margin + pitch * wanted_buttons { wanted_buttons } else { 0 };
+ // `BUTTON_CLUSTER_MARGIN` included, not just the buttons' own `pitch *
+ // button_count` span - the cluster's own leading gap needs reserving
+ // too, or a long title's text could draw underneath it (or, on the
+ // `buttons_left` side, right through the gap between the titlebar's
+ // real edge and the first button).
+ let reserved = if button_count > 0 { cluster_margin + pitch * button_count } else { 0 };
+ let (text_start, text_limit) = if buttons_left { (reserved as f32, width as f32) } else { (TEXT_LEFT_PADDING, width.saturating_sub(reserved) as f32) };
+
+ if let Some(font) = find_system_font() {
+ let baseline = (height as f32 * 0.72).round();
+ // Rasterized up front, not drawn incrementally in one pass - see
+ // `title_centered`'s own doc comment: centering needs the title's
+ // total advance width known *before* the first pixel is placed,
+ // and reusing these glyphs for the real draw below avoids
+ // rasterizing every character twice just to get there. Same
+ // truncation rule as before this existed: a glyph whose own
+ // advance crosses `text_limit` still gets drawn (matches a real
+ // window's furniture starting exactly at `text_limit`, not one
+ // glyph-width short of it), only the *next* one is dropped.
+ let mut glyphs: Vec<(fontdue::Metrics, Vec<u8>)> = Vec::new();
+ let mut total_width = 0.0f32;
+ for ch in title.chars() {
+ if ch.is_control() {
+ continue;
+ }
+ let (metrics, coverage) = font.rasterize(ch, FONT_PIXELS);
+ let advance = metrics.advance_width;
+ let already_past_limit = text_start + total_width >= text_limit;
+ if already_past_limit {
+ break;
+ }
+ total_width += advance;
+ glyphs.push((metrics, coverage));
+ }
+ // Centered on the *whole* titlebar width, not on the narrower
+ // `text_start..text_limit` span left over after reserving the
+ // button squares - matches real macOS, which ignores its own
+ // traffic-light cluster for centering purposes rather than
+ // centering in the remaining space. Centering in the reserved
+ // span instead (the previous behaviour) put the text visibly off
+ // the window's true center - for a 3-button, 30px-tall titlebar
+ // that's a 90px reservation, shifting the centered point 45px
+ // off true center, exactly the "not real center" a user would
+ // notice at a glance. Still clamped into `text_start..text_limit`
+ // afterward so a long title never draws under the buttons.
+ let start_x = if centered { ((width as f32 - total_width) / 2.0).max(text_start).min((text_limit - total_width).max(text_start)) } else { text_start };
+ let mut pen_x = start_x;
+ for (metrics, coverage) in &glyphs {
+ if metrics.width > 0 && metrics.height > 0 {
+ let glyph_x = pen_x + metrics.xmin as f32;
+ let glyph_y = baseline - metrics.height as f32 - metrics.ymin as f32;
+ blit_glyph(&mut buf, width, height, glyph_x.round() as i32, glyph_y.round() as i32, metrics, coverage, background, foreground);
+ }
+ pen_x += metrics.advance_width;
+ }
+ }
+
+ if button_count > 0 {
+ let (mut close_c, mut minimize_c, mut maximize_c) = if !traffic_lights {
+ // Unused in this mode (no dot is ever filled at rest - see the
+ // `traffic_lights` branch below), except as the base colour
+ // `brighten` starts from for the neutral hover backdrop.
+ (background, background, background)
+ } else if focused {
+ (TRAFFIC_LIGHT_CLOSE, TRAFFIC_LIGHT_MINIMIZE, TRAFFIC_LIGHT_MAXIMIZE)
+ } else {
+ (TRAFFIC_LIGHT_INACTIVE, TRAFFIC_LIGHT_INACTIVE, TRAFFIC_LIGHT_INACTIVE)
+ };
+ // Explicitly requested background-highlight-on-hover for the
+ // titlebar buttons (see docs/TODO.md) - brightens whichever one
+ // is actually hovered, close included, rather than giving close a
+ // separate hardcoded hover colour: close is already red at rest
+ // (focused) or grey (unfocused), same as the other two, so
+ // "red-on-hover for close" falls out of this same brightening,
+ // not a special case. Brightened as soon as a hover is in
+ // progress at all (any glyph alpha > 0), not gated on it having
+ // finished animating in - the circle brightening and the glyph
+ // reveal read as one combined "waking up" motion when they start
+ // together, not two separately-timed effects.
+ //
+ // One button at a time, not the whole cluster - a group-hover
+ // version (matching real macOS's own behaviour) was tried in this
+ // same session and explicitly reverted: the user confirmed this
+ // project's own convention is per-button, not per-cluster, despite
+ // what real macOS itself does.
+ let (mut close_glyph, mut minimize_glyph, mut maximize_glyph) = (0u8, 0u8, 0u8);
+ match hovered {
+ Some((srdwm_core::TitlebarHit::Close, a)) => {
+ close_c = brighten(close_c);
+ close_glyph = a;
+ }
+ Some((srdwm_core::TitlebarHit::Minimize, a)) => {
+ minimize_c = brighten(minimize_c);
+ minimize_glyph = a;
+ }
+ Some((srdwm_core::TitlebarHit::Maximize, a)) => {
+ maximize_c = brighten(maximize_c);
+ maximize_glyph = a;
+ }
+ _ => {}
+ }
+ // Traditional (non-traffic-light) glyphs are always visible, same
+ // as a real Windows/GNOME titlebar's own icons - there's no
+ // filled dot drawing attention to the button at rest the way a
+ // traffic light does, so hiding the glyph too, pending an explicit
+ // `button_glyph = "always"`, would leave the button showing
+ // nothing at all until hovered.
+ let glyph_always = glyph_always || !traffic_lights;
+ if glyph_always {
+ close_glyph = 255;
+ minimize_glyph = 255;
+ maximize_glyph = 255;
+ }
+ // A traffic-light glyph is `darken`ed from that *same* button's own
+ // (possibly already-`brighten`ed-by-hover) colour - real macOS
+ // draws a dark red mark on the red button, dark amber on the
+ // yellow one, not one shared tint reused across all three (see
+ // `darken`'s own doc comment). A traditional glyph instead uses
+ // the titlebar's actual text colour, drawn straight on the
+ // titlebar's own dark background - a dark-on-dark glyph the
+ // traffic-light shade uses would be unreadable there.
+ let (close_shade, minimize_shade, maximize_shade) =
+ if traffic_lights { (darken(close_c), darken(minimize_c), darken(maximize_c)) } else { (foreground, foreground, foreground) };
+ let margin = if buttons_left { BUTTON_MARGIN_LEFT } else { BUTTON_MARGIN };
+ // Closest-to-the-aligned-edge first - must stay in exact
+ // agreement with `ResizeEdge::hit_test`'s own resolution of the
+ // same two fields, the same "renders on one side, hit-tests on
+ // the other" trap `buttons_left` alone already has to avoid. See
+ // `ButtonOrder`'s own doc comment for why the two built-in
+ // defaults are genuinely different relative orderings, not
+ // mirrors of each other.
+ // A dialog always draws Close, full stop - not just whichever
+ // button a `button_order` override would otherwise put first, or
+ // Minimize/Maximize could still end up the one (and only) button
+ // drawn. `button_count` (1 for a dialog) caps the loop below to
+ // just this first slot either way.
+ let order: srdwm_core::ButtonOrder = if is_dialog {
+ [srdwm_core::TitlebarButton::Close; 3]
+ } else {
+ button_order.unwrap_or(if buttons_left {
+ [srdwm_core::TitlebarButton::Close, srdwm_core::TitlebarButton::Minimize, srdwm_core::TitlebarButton::Maximize]
+ } else {
+ [srdwm_core::TitlebarButton::Close, srdwm_core::TitlebarButton::Maximize, srdwm_core::TitlebarButton::Minimize]
+ })
+ };
+ // `BUTTON_CLUSTER_MARGIN` first, then each button's own `pitch * i`
+ // spacing after it - must stay in agreement with `ResizeEdge::
+ // hit_test`'s matching `left`/`right` base, the same "renders on
+ // one side, hit-tests on the other" trap every other button-
+ // geometry value here already has to avoid.
+ for (i, button) in order.iter().take(button_count).enumerate() {
+ let offset = srdwm_core::BUTTON_CLUSTER_MARGIN as usize + pitch * i;
+ match button {
+ srdwm_core::TitlebarButton::Close => {
+ // Traditional mode has no dot at rest - only once this
+ // button is actually the hovered one (`close_glyph > 0`,
+ // the same signal the glyph reveal itself already uses)
+ // does the neutral, brightened backdrop appear at all.
+ // A traffic light always fills, rest state included.
+ if traffic_lights || close_glyph > 0 {
+ fill_button_dot(&mut buf, width, height, offset, buttons_left, margin, close_c);
+ }
+ draw_close_glyph(&mut buf, width, height, offset, buttons_left, margin, close_glyph, close_shade);
+ }
+ srdwm_core::TitlebarButton::Minimize => {
+ if traffic_lights || minimize_glyph > 0 {
+ fill_button_dot(&mut buf, width, height, offset, buttons_left, margin, minimize_c);
+ }
+ draw_minimize_glyph(&mut buf, width, height, offset, buttons_left, margin, minimize_glyph, minimize_shade);
+ }
+ srdwm_core::TitlebarButton::Maximize => {
+ if traffic_lights || maximize_glyph > 0 {
+ fill_button_dot(&mut buf, width, height, offset, buttons_left, margin, maximize_c);
+ }
+ // The macOS "zoom" double-arrow only reads correctly
+ // paired with that same convention's traffic lights --
+ // traditional mode keeps the plain square every other
+ // desktop's own maximize icon already uses.
+ if traffic_lights {
+ draw_zoom_glyph(&mut buf, width, height, offset, buttons_left, margin, maximize_glyph, maximize_shade);
+ } else {
+ draw_maximize_glyph(&mut buf, width, height, offset, buttons_left, margin, maximize_glyph, maximize_shade);
+ }
+ }
+ }
+ }
+ }
+ if round_corners {
+ round_top_corners(&mut buf, width, height, radius, radius as i32 - border_width as i32);
+ }
+ buf
+}
+