diff options
| author | srdusr <[email protected]> | 2025-02-15 14:56:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-02-15 14:56:00 +0200 |
| commit | 0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd (patch) | |
| tree | 7672d0af277664f457c6c9462925c0005fe35dcf /crates/wayland/src/decoration | |
| parent | 413daa7ba2ea0ebd1424c024fd0566423aaea3f8 (diff) | |
| download | srdwm-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.rs | 184 | ||||
| -rw-r--r-- | crates/wayland/src/decoration/buttons.rs | 291 | ||||
| -rw-r--r-- | crates/wayland/src/decoration/color.rs | 34 | ||||
| -rw-r--r-- | crates/wayland/src/decoration/corners.rs | 165 | ||||
| -rw-r--r-- | crates/wayland/src/decoration/font.rs | 146 | ||||
| -rw-r--r-- | crates/wayland/src/decoration/shadow.rs | 186 | ||||
| -rw-r--r-- | crates/wayland/src/decoration/tests.rs | 756 | ||||
| -rw-r--r-- | crates/wayland/src/decoration/titlebar.rs | 295 |
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(¢ered).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 +} + |