srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/wayland/src/decoration/buttons.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/wayland/src/decoration/buttons.rs')
-rw-r--r--crates/wayland/src/decoration/buttons.rs291
1 files changed, 291 insertions, 0 deletions
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);
+}
+