srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/wayland/src/decoration.rs
blob: 4aa0b2d27a9870dddfef999775cc69620ce8cbbd (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
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
//! Software rasterization of the titlebar band: solid background + drawn
//! title text, as a BGRA8 pixel buffer (the byte order `Fourcc::Argb8888`
//! expects when uploaded via `smithay`'s `GlesRenderer`, see
//! `format::gl_internal_format` - it maps to `GL_BGRA_EXT`/`GL_UNSIGNED_BYTE`).
//!
//! Deliberately has zero `smithay` dependency: it's a pure `(width, height,
//! text) -> Vec<u8>` function, unit-testable without a GL context or
//! display, with a thin adapter in `lib.rs` uploading the result into a
//! `MemoryRenderBuffer`.

use fontdue::{Font, FontSettings};
use std::sync::OnceLock;

const FONT_PIXELS: f32 = 13.0;
const TEXT_LEFT_PADDING: f32 = 8.0;

/// Titlebar buttons are laid out right-aligned in `height`-wide squares --
/// matching `ResizeEdge::hit_test` in `crates/core/src/window.rs`, whose
/// `BUTTON` constant is also `TITLEBAR_HEIGHT`. 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.
const BUTTON_MARGIN: f32 = 0.32;

/// 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.
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> = None;
    for root in all_roots {
        find_ttf_preferring_mono(std::path::Path::new(&root), &mut best);
        if best.is_some() {
            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
        }
    }
}

/// Walks `dir` looking for a `.ttf`/`.otf` file, preferring one whose name
/// contains "mono". Stops early once a mono-named file is found.
fn find_ttf_preferring_mono(dir: &std::path::Path, best: &mut Option<std::path::PathBuf>) {
    let Ok(entries) = std::fs::read_dir(dir) else { return };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            find_ttf_preferring_mono(&path, best);
            if matches!(best, Some(p) if p.to_string_lossy().to_lowercase().contains("mono")) {
                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 is_mono = path.to_string_lossy().to_lowercase().contains("mono");
        if is_mono {
            *best = Some(path);
            return;
        }
        if best.is_none() {
            *best = Some(path);
        }
    }
}

fn rgb_to_bgra(rgb: (u8, u8, u8), alpha: u8) -> [u8; 4] {
    [rgb.2, rgb.1, rgb.0, alpha]
}

/// The titlebar right-click window menu (minimize/maximize/always-on-top/
/// close) - the one interaction virtually every desktop WM has always
/// offered on a titlebar that srdwm never did (right-click there was only
/// ever the SUPER+right-drag resize gesture, and only with the modifier
/// held). `items` is `(label, highlighted)`; `row_height` matches
/// `TITLEBAR_HEIGHT` by convention at the call site, not enforced here.
///
/// Deliberately plain: solid rows, left-padded text, a 1px border for
/// definition against whatever's behind it - no submenus, no icons, no
/// separators. A context menu widget with real visual polish is a project
/// of its own; this is the minimum that makes the actions discoverable and
/// clickable at all, which is the actual gap.
pub fn render_context_menu(width: u32, row_height: u32, items: &[(&str, bool)], bg: (u8, u8, u8), fg: (u8, u8, u8), highlight_bg: (u8, u8, u8), border: (u8, u8, u8)) -> Vec<u8> {
    let (width, row_height) = (width.max(1) as usize, row_height.max(1) as usize);
    let height = (row_height * items.len().max(1)).max(1);
    let mut buf = vec![0u8; width * height * 4];

    let font = find_system_font();
    for (i, (label, highlighted)) in items.iter().enumerate() {
        let row_bg = if *highlighted { highlight_bg } else { bg };
        let row_top = i * row_height;
        for y in row_top..(row_top + row_height).min(height) {
            for x in 0..width {
                let idx = (y * width + x) * 4;
                buf[idx..idx + 4].copy_from_slice(&rgb_to_bgra(row_bg, 255));
            }
        }
        if let Some(font) = &font {
            let baseline = row_top as f32 + row_height as f32 * 0.72;
            let mut pen_x = TEXT_LEFT_PADDING;
            for ch in label.chars() {
                if ch.is_control() {
                    continue;
                }
                let (metrics, coverage) = font.rasterize(ch, FONT_PIXELS);
                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, row_bg, fg);
                }
                pen_x += metrics.advance_width;
                if pen_x as usize >= width {
                    break;
                }
            }
        }
    }

    // A 1px border around the whole menu, drawn last so it isn't overdrawn
    // by any row's background fill.
    let border_px = rgb_to_bgra(border, 255);
    for x in 0..width {
        buf[x * 4..x * 4 + 4].copy_from_slice(&border_px);
        let last_row = (height - 1) * width + x;
        buf[last_row * 4..last_row * 4 + 4].copy_from_slice(&border_px);
    }
    for y in 0..height {
        let left = y * width;
        buf[left * 4..left * 4 + 4].copy_from_slice(&border_px);
        let right = y * width + width - 1;
        buf[right * 4..right * 4 + 4].copy_from_slice(&border_px);
    }
    buf
}

/// The four border strips (top, bottom, left, right) around a window's
/// full rect, `width` thick, drawn *outside* `geometry` - additive to the
/// window's on-screen footprint, the same as a native X11 border, rather
/// than overlapping and clipping into the titlebar or content. This is
/// purely a rendering concern: `geometry` alone stays authoritative for
/// hit-testing and placement, nothing reads the strips back.
///
/// Without any border at all, a compositor-drawn titlebar and independently
/// client-rendered content have nothing visually tying them together as
/// one window - reported live as the titlebar "not seeming part of the
/// window". `Window.border_color`/`border_width` already existed (and are
/// drawn by the X11 backend via a native X11 border) but were dead fields
/// on the Wayland side - `set_border_color`/`set_border_width` were both
/// no-op stubs.
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),
    ]
}

/// How far a window's drop shadow extends past its geometry on each side.
pub const SHADOW_SIZE: u32 = 12;

/// 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.
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 `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).
pub fn shadow_bitmap(win_width: u32, win_height: u32) -> 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;
    let mut buf = vec![0u8; (width * height * 4) as usize];
    for y in 0..height {
        let dy = edge_distance(y, SHADOW_SIZE, win_height);
        if dy > SHADOW_SIZE {
            continue;
        }
        for x in 0..width {
            let dx = edge_distance(x, SHADOW_SIZE, win_width);
            let dist = dx.max(dy);
            if dist == 0 || dist > SHADOW_SIZE {
                continue;
            }
            let alpha = (SHADOW_MAX_ALPHA as u32 * (SHADOW_SIZE - dist) / SHADOW_SIZE) 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
}

/// 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
    }
}

/// 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 `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. Rounding *this* strip
/// too, at a radius `width` pixels larger than the titlebar's (so the cut
/// continues outward from the titlebar's own, rather than starting over),
/// is what makes a bordered window's corner read as one continuous curve
/// instead of a rounded titlebar sitting inside a square frame. The other
/// three strips (bottom/left/right) don't participate in any visible
/// corner and stay plain solid fills - see their render call sites.
pub fn render_border_top(width: u32, thickness: u32, color: (u8, u8, u8)) -> Vec<u8> {
    let (width, thickness) = (width.max(1) as usize, thickness.max(1) as usize);
    let bg = rgb_to_bgra(color, 255);
    let mut buf = vec![0u8; width * thickness * 4];
    for px in buf.chunks_exact_mut(4) {
        px.copy_from_slice(&bg);
    }
    round_top_corners(&mut buf, width, thickness, CORNER_RADIUS + thickness as u32);
    buf
}

/// 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. `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.
pub fn render_titlebar(width: u32, height: u32, title: &str, background: (u8, u8, u8), foreground: (u8, u8, u8), round_corners: bool) -> Vec<u8> {
    let (width, height) = (width.max(1) as usize, height.max(1) as usize);
    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 right-hand button squares 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.
    let button_count = if width >= height * 3 { 3 } else { 0 };
    let text_limit = width.saturating_sub(height * button_count);

    if let Some(font) = find_system_font() {
        let baseline = (height as f32 * 0.72).round();
        let mut pen_x = TEXT_LEFT_PADDING;
        for ch in title.chars() {
            if ch.is_control() {
                continue;
            }
            let (metrics, coverage) = font.rasterize(ch, FONT_PIXELS);
            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 pen_x as usize >= text_limit {
                break;
            }
        }
    }

    if button_count == 3 {
        draw_minimize_icon(&mut buf, width, height, height * 2, foreground);
        draw_maximize_icon(&mut buf, width, height, height, foreground);
        draw_close_icon(&mut buf, width, height, 0, foreground);
    }
    if round_corners {
        round_top_corners(&mut buf, width, height, CORNER_RADIUS);
    }
    buf
}

/// How many pixels of each top corner are clipped away by
/// `round_top_corners`. Small and fixed rather than configurable: this is a
/// cosmetic nicety, not a feature surface worth a `srd.theme` knob, and a
/// value this small barely reads as "rounded" if it gets any larger at the
/// titlebar heights this compositor actually uses.
///
/// `pub(crate)`, not private: `rounded_corners.rs` reuses the exact same
/// value for a decorated window's own content (its bottom two corners,
/// where the GLES backend can round the client's actual pixels, unlike
/// the CPU bitmap clip this file does for the titlebar/border), so a
/// bordered window's curve reads as one continuous radius from titlebar
/// to content rather than two different ones meeting at a seam.
pub(crate) const CORNER_RADIUS: u32 = 6;

/// 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.
fn round_top_corners(buf: &mut [u8], width: usize, height: usize, radius: u32) {
    let r = (radius as usize).min(width / 2).min(height);
    if r == 0 {
        return;
    }
    // Corner centres: `r` in from each edge, `r` down from the top - the
    // standard quarter-circle-in-a-square construction.
    let is_outside_corner = |x: usize, y: usize, cx: usize, cy: usize| -> bool {
        let (dx, dy) = (x as i64 - cx as i64, y as i64 - cy as i64);
        (dx * dx + dy * dy) as u64 > (r * r) as u64
    };
    for y in 0..r {
        for x in 0..r {
            if is_outside_corner(x, y, r, r) {
                buf[(y * width + x) * 4 + 3] = 0;
            }
        }
        for x in (width - r)..width {
            if is_outside_corner(x, y, width - r - 1, r) {
                buf[(y * width + x) * 4 + 3] = 0;
            }
        }
    }
}

/// Sets one pixel to `color` if it falls inside the buffer - every icon
/// drawn below goes through this so none of them need their own bounds
/// checks.
fn set_px(buf: &mut [u8], width: usize, height: usize, x: i32, y: i32, color: (u8, u8, u8)) {
    if x < 0 || y < 0 || x as usize >= width || y as usize >= height {
        return;
    }
    let idx = (y as usize * width + x as usize) * 4;
    buf[idx..idx + 4].copy_from_slice(&rgb_to_bgra(color, 255));
}

/// The square `right_offset` pixels in from the right edge of the titlebar,
/// inset by `BUTTON_MARGIN` on each side - the box a button's glyph is
/// drawn inside.
fn button_box(width: usize, height: usize, right_offset: usize) -> (i32, i32, i32, i32) {
    let square = height as f32;
    let inset = (square * BUTTON_MARGIN).round() as i32;
    let right = width as i32 - right_offset as i32;
    let left = right - height as i32;
    (left + inset, inset, right - inset, height as i32 - inset)
}

/// Bresenham line, since none of these icons need anything fancier.
fn draw_line(buf: &mut [u8], width: usize, height: usize, x0: i32, y0: i32, x1: i32, y1: i32, color: (u8, u8, u8)) {
    let (mut x0, mut y0) = (x0, y0);
    let dx = (x1 - x0).abs();
    let dy = -(y1 - y0).abs();
    let sx = if x0 < x1 { 1 } else { -1 };
    let sy = if y0 < y1 { 1 } else { -1 };
    let mut err = dx + dy;
    loop {
        set_px(buf, width, height, x0, y0, color);
        if x0 == x1 && y0 == y1 {
            break;
        }
        let e2 = 2 * err;
        if e2 >= dy {
            err += dy;
            x0 += sx;
        }
        if e2 <= dx {
            err += dx;
            y0 += sy;
        }
    }
}

fn draw_close_icon(buf: &mut [u8], width: usize, height: usize, right_offset: usize, color: (u8, u8, u8)) {
    let (x0, y0, x1, y1) = button_box(width, height, right_offset);
    draw_line(buf, width, height, x0, y0, x1, y1, color);
    draw_line(buf, width, height, x0, y1, x1, y0, color);
}

fn draw_maximize_icon(buf: &mut [u8], width: usize, height: usize, right_offset: usize, color: (u8, u8, u8)) {
    let (x0, y0, x1, y1) = button_box(width, height, right_offset);
    draw_line(buf, width, height, x0, y0, x1, y0, color);
    draw_line(buf, width, height, x0, y1, x1, y1, color);
    draw_line(buf, width, height, x0, y0, x0, y1, color);
    draw_line(buf, width, height, x1, y0, x1, y1, color);
}

fn draw_minimize_icon(buf: &mut [u8], width: usize, height: usize, right_offset: usize, color: (u8, u8, u8)) {
    let (x0, _, x1, y1) = button_box(width, height, right_offset);
    draw_line(buf, width, height, x0, y1, x1, y1, color);
}

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

#[cfg(test)]
mod tests {
    use super::*;

    #[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);
        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);
        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 fills_background_when_no_text() {
        let buf = render_titlebar(40, 20, "", (0x2e, 0x34, 0x40), (0xec, 0xef, 0xf4), true);
        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);
        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);
        for (right_offset, expected) in [(0, srdwm_core::TitlebarHit::Close), (height, srdwm_core::TitlebarHit::Maximize), (height * 2, srdwm_core::TitlebarHit::Minimize)] {
            let (x0, y0, x1, y1) = button_box(width, height, right_offset);
            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),
                Some(expected),
                "icon drawn at right_offset={right_offset} does not land in the square hit_test assigns to {expected:?}"
            );
        }
    }

    #[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);
        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 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);
        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);
        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 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);
        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 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);
        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");
        // A 2px-thick strip is thinner than any sane radius, so the clamp
        // in `round_top_corners` bounds the cut to the strip's own height --
        // the bottom row, at least at the strip's horizontal centre, must
        // stay opaque or there would be no border left to see at all.
        assert_eq!(alpha_at(width as usize / 2, thickness as usize - 1), 255, "centre of the strip 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);
    }
}