srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/wayland/src/decoration/buttons.rs
blob: 07de32cda7f0151a4a27945f193746da077567de (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
//! 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).
#[allow(clippy::too_many_arguments)]
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)
}

#[allow(clippy::too_many_arguments)]
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.
#[allow(clippy::too_many_arguments)]
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);
}

#[allow(clippy::too_many_arguments)]
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.
#[allow(clippy::too_many_arguments)]
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);
}