srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/wayland/src/decoration.rs
blob: 49cf416ae75bc412d4182db4150d6ec47ec4df50 (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
//! Software rasterization of every hand-drawn piece of window chrome this
//! compositor's own CPU/Pixman render path draws itself: the titlebar band
//! (background, title text, button cluster), the border strips, the drop
//! shadow, and two small standalone popups (the titlebar right-click menu,
//! the Snap-Layouts flyout) - all as BGRA8 pixel buffers (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 throughout: every function
//! here is a pure `(dimensions, ...) -> Vec<u8>` call, unit-testable
//! without a GL context or display, with a thin adapter in `lib.rs`
//! uploading each result into a `MemoryRenderBuffer`.
//!
//! Split by concern, matching niri's own `render_helpers/` module
//! boundaries (see `docs/TODO.md`'s "module splits" entry for the research
//! behind that choice) - this file itself only re-exports each
//! submodule's own public API plus the two standalone popup renderers that
//! don't belong to any single one of them:
//! - [`color`]: byte-order conversion and the two directional colour
//!   blends (`brighten`/`darken`) shared by several submodules.
//! - [`font`]: locating a system monospace font and blitting its
//!   rasterized glyphs.
//! - [`corners`]: rounding a bitmap's own top/bottom corners to a
//!   quarter-circle - shared by `titlebar`/`border`.
//! - [`shadow`]: a window's drop shadow.
//! - [`border`]: the four strips around a window's `geometry`.
//! - [`buttons`]: the three titlebar buttons' own dots and glyphs.
//! - [`titlebar`]: laying out and rasterizing the whole titlebar band,
//!   using `buttons`/`corners`/`font`/`color`.

mod border;
mod buttons;
mod color;
mod corners;
mod font;
mod shadow;
mod titlebar;

pub use border::{border_strips, render_border_bottom, render_border_top};
pub(crate) use border::{border_bottom_visible_rows, border_top_visible_rows};
pub(crate) use buttons::HOVER_GLYPH_DURATION;
pub(crate) use color::{mix_rgb, rgb_to_bgra};
pub(crate) use corners::{round_bottom_corners, round_top_corners};
pub(crate) use font::{blit_glyph, find_system_font, measure_text_width, FONT_PIXELS, TEXT_LEFT_PADDING};
pub use shadow::{shadow_bitmap, shadow_rect};
pub(crate) use shadow::{SHADOW_MAX_ALPHA, SHADOW_SIZE};
pub use titlebar::render_titlebar;

/// Default corner radius, in pixels, applied to a window at creation
/// (`Window::corner_radius`/`ThemeConfig::default_corner_radius`) - kept
/// here only as this file's own test fixture default now that the real
/// radius is a live, per-window value (`theme.decorations.border.radius`
/// in config, `srd set corner_radius <n>` live, `srd.window.
/// set_corner_radius(n)`/a rule's `corner_radius` action per-window).
/// `render_border_top`/`render_border_bottom`/`render_titlebar` all take
/// the real radius as a parameter now rather than reading this directly.
/// `12`, not the original `6`: a `radius / TITLEBAR_HEIGHT` ratio of
/// `0.36`, matching real macOS's own ~10pt/28pt proportions rather than
/// this project's original, visibly-tighter `0.2` (docs/TODO.md's
/// macOS-comparison research) - kept in sync with `ThemeConfig::
/// default_corner_radius` and the shipped `theme.decorations.border.radius`
/// default in `crates/srdwm/src/main.rs` so this fixture actually
/// represents what a fresh install renders. Moves in step with
/// `srdwm_core::TITLEBAR_HEIGHT` (currently `32`) - same ratio, not a
/// separate size decision.
#[cfg(test)]
pub(crate) const CORNER_RADIUS: u32 = 12;

/// 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.
///
/// Rounded floating panel with a per-row rounded hover highlight, matching
/// the reference this project's own AGS panel already settled on for its
/// global-menu dropdown (`widget/Bar/components/GlobalMenu/style.scss`'s
/// `popover box.menu-list`): flat rows with no border/outline at rest, a
/// soft tinted fill (not a frame) on the highlighted one, inset padding so
/// rows don't touch the panel's own edge, real gaps between rows. Reported
/// live as looking "squished, no spacing/padding/margining, not at all
/// polished" - the previous version drew edge-to-edge square rows with a
/// single hard 1px border around the whole menu, exactly what that
/// complaint (raised about the AGS dropdown, fixed there first) describes.
/// Still no submenus/icons - real gaps beyond this pass' own scope, not
/// attempted blind.
///
/// A row's shape, alongside its own label/height - `render_context_menu`
/// used to detect a divider by checking whether a label was made entirely
/// of the box-drawing character `─`, and had no representation for a
/// section-header row at all (`"─── Move to Workspace ───"` rendered, and
/// behaved, as an ordinary clickable-looking item that merely did
/// nothing). Reported live as looking wrong on both counts - a real,
/// distinct row kind is what `srdwm_core::context_menu::MenuAction`'s own
/// `Separator`/`Header` variants exist for; this mirrors that split here
/// without this crate needing to depend on that enum itself.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum MenuRowKind {
    Item,
    /// A thin divider line, no label.
    Separator,
    /// A non-interactive section caption - dimmer, smaller text, never
    /// highlighted.
    Header,
}

/// Rounded floating panel with a per-row rounded hover highlight, matching
/// the reference this project's own AGS panel already settled on for its
/// global-menu dropdown (`widget/Bar/components/GlobalMenu/style.scss`'s
/// `popover box.menu-list`): flat rows with no border/outline at rest, a
/// soft tinted fill (not a frame) on the highlighted one, inset padding so
/// rows don't touch the panel's own edge, real gaps between rows. Reported
/// live as looking "squished, no spacing/padding/margining, not at all
/// polished" - the previous version drew edge-to-edge square rows with a
/// single hard 1px border around the whole menu, exactly what that
/// complaint (raised about the AGS dropdown, fixed there first) describes.
/// Still no submenus/icons - real gaps beyond this pass' own scope, not
/// attempted blind.
///
/// `rows` carries each row's own height alongside its label/kind - a
/// [`MenuRowKind::Separator`]/[`MenuRowKind::Header`] row is much shorter
/// than a real item (see `srdwm_core::context_menu`'s own `SEPARATOR_
/// HEIGHT`/`HEADER_HEIGHT`), reported live as looking wrong when every
/// row, divider included, took the same full item height: a hairline
/// sitting in the middle of a mostly-empty 32px slot. The caller is what
/// actually knows each row's real height (`ContextMenu::row_height_for`);
/// this function just draws whatever it's told, at the offsets implied by
/// summing those heights in order - the same summing `ContextMenu::row_
/// at`/`row_y` do, so a click and a pixel can never disagree about where a
/// row actually is.
///
/// A [`MenuRowKind::Separator`] is drawn as a real 1px anti-aliased line,
/// inset from both edges and blended low-opacity against the panel
/// (matching the AGS reference dropdown's own `separator.menu-sep`:
/// `color-mix(in srgb, var(--fg) 12%, transparent)`) - pixels, not
/// Unicode box-drawing characters, which render inconsistently thin/
/// dotted across fonts at small sizes. A [`MenuRowKind::Header`] draws its
/// label dimmed (the same mix ratio a separator's own line uses) and
/// never highlighted, regardless of what the caller passes for that row's
/// `highlighted` field - section captions aren't clickable, so nothing
/// should ever visually suggest they are.
pub fn render_context_menu(width: u32, rows: &[(&str, bool, u32, MenuRowKind)], bg: (u8, u8, u8), fg: (u8, u8, u8), highlight_bg: (u8, u8, u8), border: (u8, u8, u8)) -> Vec<u8> {
    let _ = border; // No outline anywhere now - see this function's own doc comment. Kept as a parameter so callers/themes don't need updating for a look this function no longer draws.
    const PANEL_RADIUS: f32 = 10.0;
    const ROW_INSET: i32 = 4;
    const ROW_RADIUS: f32 = 6.0;
    // AGS reference's own measured/settled ratio (`popover box.menu-list
    // button:hover`'s `color-mix(in srgb, var(--primary-bg) 22%, var(
    // --widget-bg))`) - a subtle tinted wash, not a flat, fully-
    // saturated fill of whatever colour the caller passes as `highlight_
    // bg`. Applied here rather than changing what callers pass in, so
    // every existing call site's own colour choice still means "the
    // accent to tint toward", not "the literal pixel colour".
    const HIGHLIGHT_MIX: f32 = 0.22;
    // Matches the reference's own `separator.menu-sep` background --
    // barely-there, a hairline rather than a visible bar. Reused as the
    // header caption's own dim ratio - both exist to read as "present,
    // but deliberately not the main content" against the same panel.
    const DIM_MIX: f32 = 0.12;

    let width = width.max(1) as usize;
    let height = rows.iter().map(|(_, _, h, _)| *h).sum::<u32>().max(1) as usize;
    let mut buf = vec![0u8; width * height * 4];

    // The panel itself: one flat rounded-rect fill on an otherwise fully
    // transparent canvas, so the corners genuinely show whatever's behind
    // the menu (desktop/window content) rather than a hard-edged square.
    fill_rounded_rect(&mut buf, width, height, 0, 0, width as i32, height as i32, PANEL_RADIUS, bg, bg);

    let highlight_fill = mix_rgb(bg, highlight_bg, HIGHLIGHT_MIX);
    let dim_color = mix_rgb(bg, fg, DIM_MIX);
    let font = find_system_font();
    let mut row_top: i32 = 0;
    for (label, highlighted, row_height, kind) in rows.iter().copied() {
        let row_height = row_height as i32;
        if kind == MenuRowKind::Separator {
            let y = row_top + row_height / 2;
            fill_rounded_rect_over(&mut buf, width, height, ROW_INSET * 2, y, width as i32 - ROW_INSET * 2, y + 1, 0.0, dim_color);
            row_top += row_height;
            continue;
        }
        let highlighted = highlighted && kind != MenuRowKind::Header;
        let label_color = if kind == MenuRowKind::Header { dim_color } else { fg };
        // The background text actually sits on, for `blit_glyph`'s own
        // blend-toward-a-known-solid-colour contract - the row's own
        // highlight fill (already baked into `buf` by this point, above)
        // when highlighted, otherwise the panel's shared flat fill.
        // `blit_glyph_on_transparent` would be wrong here even though most
        // of `buf` started transparent: every row itself sits on the
        // panel's own opaque fill, not bare transparency, and that
        // blitter's whole design assumes the latter (see its own doc
        // comment) - used correctly, it would leave a visible dark
        // fringe around every character's anti-aliased edge instead of a
        // clean blend into the row's real colour.
        let row_bg = if highlighted { highlight_fill } else { bg };
        if highlighted {
            fill_rounded_rect_over(&mut buf, width, height, ROW_INSET, row_top, width as i32 - ROW_INSET, row_top + row_height, ROW_RADIUS, highlight_fill);
        }
        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, label_color);
                }
                pen_x += metrics.advance_width;
                if pen_x as usize >= width {
                    break;
                }
            }
        }
        row_top += row_height;
    }
    buf
}

/// The Snap-Layouts flyout (`crates/wayland/src/snap_flyout.rs`) - a
/// `columns`-wide grid of labeled cells, one per `SnapZoneKind`. Same
/// "deliberately plain" bar `render_context_menu` above sets: solid cells,
/// left-padded text, grid lines and an outer border in one colour - no
/// live preview thumbnails, no icons.
pub fn render_snap_flyout(columns: u32, cell_width: u32, cell_height: u32, labels: &[&str], bg: (u8, u8, u8), fg: (u8, u8, u8), border: (u8, u8, u8)) -> Vec<u8> {
    let (cell_width, cell_height, columns) = (cell_width.max(1) as usize, cell_height.max(1) as usize, columns.max(1) as usize);
    let rows = labels.len().div_ceil(columns).max(1);
    let width = cell_width * columns;
    let height = cell_height * rows;
    let mut buf = vec![0u8; width * height * 4];

    let bg_px = rgb_to_bgra(bg, 255);
    for px in buf.chunks_exact_mut(4) {
        px.copy_from_slice(&bg_px);
    }

    let font = find_system_font();
    for (i, label) in labels.iter().enumerate() {
        let (col, row) = (i % columns, i / columns);
        let (cell_x, cell_y) = (col * cell_width, row * cell_height);
        if let Some(font) = &font {
            let baseline = cell_y as f32 + cell_height as f32 * 0.58;
            let mut pen_x = cell_x as f32 + 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, bg, fg);
                }
                pen_x += metrics.advance_width;
                if pen_x as usize >= cell_x + cell_width {
                    break;
                }
            }
        }
    }

    // Grid lines (including the outer border) drawn last, one colour, so
    // nothing overdraws them.
    let border_px = rgb_to_bgra(border, 255);
    for y in 0..height {
        for col in 0..=columns {
            let x = (col * cell_width).min(width - 1);
            let idx = y * width + x;
            buf[idx * 4..idx * 4 + 4].copy_from_slice(&border_px);
        }
    }
    for x in 0..width {
        for row in 0..=rows {
            let y = (row * cell_height).min(height - 1);
            let idx = y * width + x;
            buf[idx * 4..idx * 4 + 4].copy_from_slice(&border_px);
        }
    }
    buf
}

/// One desktop icon's cell: a hand-drawn glyph (no icon-theme artwork
/// exists anywhere in this workspace - see `desktop_icons.rs`'s own doc
/// comment) plus a centred label underneath, on an otherwise fully
/// transparent `width`x`height` canvas so the wallpaper shows through
/// everywhere the glyph/label don't draw. Same "deliberately plain, no
/// icon-theme fidelity" first-pass philosophy as `render_context_menu`.
///
/// `selected` draws an opaque highlight box behind the label only (not the
/// glyph) - matching the classic file-manager convention that the label,
/// not the whole cell, is what visibly marks a selection - rather than a
/// translucent overlay across the glyph, which would need premultiplied-
/// alpha blending this function has no other reason to do (every other
/// pixel here is drawn fully opaque or left fully transparent).
#[allow(clippy::too_many_arguments)]
/// The glyph area within a desktop-icon cell, shared between `render_
/// desktop_icon` (where it draws into) and its caller (which needs the
/// exact same box to know what size to rasterize a real theme icon at --
/// a mismatch would either leave a gap or need cropping, neither of which
/// `render_desktop_icon`'s own straight-copy blend handles).
pub(crate) fn desktop_icon_glyph_box(width: u32, height: u32) -> (i32, i32, i32, i32) {
    let _ = height;
    ((width as i32 - 40) / 2, 8, (width as i32 + 40) / 2, 44)
}

#[allow(clippy::too_many_arguments)]
pub(crate) fn render_desktop_icon(
    width: u32,
    height: u32,
    kind: crate::desktop_icons::IconKind,
    label: &str,
    selected: bool,
    icon_color: (u8, u8, u8),
    label_color: (u8, u8, u8),
    selected_bg: (u8, u8, u8),
    real_icon: Option<&[u8]>,
) -> Vec<u8> {
    use crate::desktop_icons::IconKind;
    let (width, height) = (width.max(1) as usize, height.max(1) as usize);
    let mut buf = vec![0u8; width * height * 4];

    let glyph_box = desktop_icon_glyph_box(width as u32, height as u32);
    if let Some(real_icon) = real_icon {
        // A real theme icon, already rasterized by `icon_theme::
        // rasterize_svg` at exactly this box's own size - premultiplied
        // BGRA8 straight from `resvg`, the same convention `blit_glyph_on_
        // transparent` below already uses, and `buf` starts fully
        // transparent (freshly zeroed) everywhere this box covers, so
        // "blend over" and "copy" are the same operation here: no alpha
        // math needed, unlike compositing onto a real background would.
        let box_w = (glyph_box.2 - glyph_box.0).max(0) as usize;
        let box_h = (glyph_box.3 - glyph_box.1).max(0) as usize;
        for row in 0..box_h {
            let y = glyph_box.1 + row as i32;
            if y < 0 || y as usize >= height {
                continue;
            }
            let src_row = &real_icon[row * box_w * 4..(row + 1) * box_w * 4];
            let dst_start = (y as usize * width + glyph_box.0.max(0) as usize) * 4;
            let copy_w = box_w.min(width.saturating_sub(glyph_box.0.max(0) as usize));
            buf[dst_start..dst_start + copy_w * 4].copy_from_slice(&src_row[..copy_w * 4]);
        }
    } else {
        // A top-lighter/bottom-`icon_color` vertical gradient, not one flat
        // fill - the same subtle top-to-bottom light-source cue `buttons.rs`'s
        // own `glossy_shade` uses for the titlebar dots, applied here as a
        // plain linear gradient (`fill_rounded_rect`'s own job) rather than a
        // radial highlight, which reads just as "polished" at this icon size
        // for a lot less code. `border` (outline/detail colour) stays a flat
        // darken of the base, unchanged. Only reached when no real icon-theme
        // artwork resolved at all (`icon_theme::find_icon` found nothing, or
        // the file it found failed to parse/render) - the fallback, not the
        // normal path on a machine with any real icon theme installed.
        let top = color::brighten(icon_color);
        let border = color::darken(icon_color);
        match kind {
            IconKind::Home => draw_home_glyph(&mut buf, width, height, glyph_box, top, icon_color, border),
            IconKind::Computer => draw_computer_glyph(&mut buf, width, height, glyph_box, top, icon_color, border),
            IconKind::Trash => draw_trash_glyph(&mut buf, width, height, glyph_box, top, icon_color, border),
            IconKind::Folder => draw_folder_glyph(&mut buf, width, height, glyph_box, top, icon_color, border),
            IconKind::File => draw_file_glyph(&mut buf, width, height, glyph_box, top, icon_color, border),
        }
    }

    let label_top = 50i32;
    if let Some(font) = find_system_font() {
        let baseline = label_top as f32 + 14.0;
        let mut widths = Vec::new();
        let mut total = 0.0f32;
        for ch in label.chars() {
            let (m, _) = font.rasterize(ch, FONT_PIXELS);
            widths.push(m.advance_width);
            total += m.advance_width;
        }
        // Reported live: the old selection highlight was a flat, edge-to-edge
        // rectangle spanning the label's whole vertical band (`label_top` to
        // `height - 2`, full cell width minus 2px either side) - "big
        // highlighting... for some reason", next to a saturated theme accent
        // colour (`selected_bg` is `theme.default_border_color`, e.g.
        // Catppuccin's mauve) it read as an oversized, disproportionate
        // block rather than a label being picked out. Real file managers
        // (Nautilus, Explorer) size the highlight to the text itself plus a
        // small margin, not to the cell - snug and rounded, same "fill, not
        // a frame" principle already applied to the context-menu rewrite.
        const LABEL_PAD_X: f32 = 6.0;
        const LABEL_PAD_Y: f32 = 3.0;
        const LABEL_RADIUS: f32 = 5.0;
        let text_height = FONT_PIXELS; // close enough for a snug box; exact ascent/descent isn't worth tracking here.
        if selected {
            let box_x0 = ((width as f32 - total) / 2.0 - LABEL_PAD_X).max(0.0);
            let box_x1 = ((width as f32 + total) / 2.0 + LABEL_PAD_X).min(width as f32);
            let box_y0 = (baseline - text_height - LABEL_PAD_Y).max(0.0);
            let box_y1 = (baseline + LABEL_PAD_Y).min(height as f32);
            fill_rounded_rect(&mut buf, width, height, box_x0.round() as i32, box_y0.round() as i32, box_x1.round() as i32, box_y1.round() as i32, LABEL_RADIUS, selected_bg, selected_bg);
        }
        let row_bg = selected_bg; // only meaningful when `selected`; `blit_glyph_on_transparent` below is used otherwise.
        let mut pen_x = ((width as f32 - total) / 2.0).max(2.0);
        for (ch, adv) in label.chars().zip(widths) {
            if ch.is_control() {
                pen_x += adv;
                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;
                if selected {
                    blit_glyph(&mut buf, width, height, glyph_x.round() as i32, glyph_y.round() as i32, &metrics, &coverage, row_bg, label_color);
                } else {
                    blit_glyph_on_transparent(&mut buf, width, height, glyph_x.round() as i32, glyph_y.round() as i32, &metrics, &coverage, label_color);
                }
            }
            pen_x += adv;
            if pen_x as usize >= width {
                break;
            }
        }
    }
    buf
}

/// Fills a straight-alpha `color` at `alpha` into every pixel of the given
/// rect, clamped to the canvas - the one primitive every glyph below is
/// built from. `alpha` is only ever `255` from any call site in this file
/// (every icon glyph is drawn fully opaque against an otherwise-transparent
/// canvas), so there's no premultiplication to get right here - straight
/// and premultiplied colour are identical at full opacity.
#[allow(clippy::too_many_arguments)]
pub(crate) fn fill_rect(buf: &mut [u8], width: usize, height: usize, x0: i32, y0: i32, x1: i32, y1: i32, color: (u8, u8, u8), alpha: u8) {
    let px = rgb_to_bgra(color, alpha);
    for y in y0.max(0)..y1.min(height as i32) {
        for x in x0.max(0)..x1.min(width as i32) {
            let idx = (y as usize * width + x as usize) * 4;
            buf[idx..idx + 4].copy_from_slice(&px);
        }
    }
}

/// Same job as `font::blit_glyph`, but for a canvas that starts fully
/// transparent rather than a known solid `background` colour to blend
/// toward - `blit_glyph` always writes full alpha, blended toward that
/// assumed background, which is wrong here: a partially-covered edge pixel
/// needs to stay partially *transparent*, not opaque-and-blended. Written
/// as real premultiplied-alpha BGRA (`rgb * alpha / 255`, matching alpha)
/// rather than straight colour at a partial alpha - see this session's own
/// `rounded_corners_pixman.rs` doc comments for why an un-premultiplied
/// partial-alpha pixel is a real, previously-hit correctness bug here, not
/// a style choice.
#[allow(clippy::too_many_arguments)]
pub(crate) fn blit_glyph_on_transparent(buf: &mut [u8], width: usize, height: usize, glyph_x: i32, glyph_y: i32, metrics: &fontdue::Metrics, coverage: &[u8], color: (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 alpha = coverage[row * metrics.width + col];
            if alpha == 0 {
                continue;
            }
            let premul = |c: u8| ((c as u32 * alpha as u32 + 127) / 255) as u8;
            let idx = (y as usize * width + x as usize) * 4;
            buf[idx..idx + 4].copy_from_slice(&rgb_to_bgra((premul(color.0), premul(color.1), premul(color.2)), alpha));
        }
    }
}

/// Same job as `fill_rect`, but with softly rounded corners (the same
/// clamp-then-distance smoothstep construction `rounded_corners_pixman::
/// apply_corner_mask` already established for real content masking, not a
/// new technique) and a vertical `top`-to-`bottom` gradient instead of one
/// flat colour - together, what actually takes a shape from "programmer
/// art" to reading as a small polished icon glyph rather than a coloured
/// rectangle.
#[allow(clippy::too_many_arguments)]
fn fill_rounded_rect(buf: &mut [u8], width: usize, height: usize, x0: i32, y0: i32, x1: i32, y1: i32, radius: f32, top: (u8, u8, u8), bottom: (u8, u8, u8)) {
    let (w, h) = (width as i32, height as i32);
    let radius = radius.min((x1 - x0) as f32 / 2.0).min((y1 - y0) as f32 / 2.0).max(0.0);
    let rect_h = (y1 - y0).max(1) as f32;
    for y in y0.max(0)..y1.min(h) {
        let t = ((y - y0) as f32 / rect_h).clamp(0.0, 1.0);
        let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8;
        let color = (lerp(top.0, bottom.0), lerp(top.1, bottom.1), lerp(top.2, bottom.2));
        for x in x0.max(0)..x1.min(w) {
            let px = x as f32 + 0.5;
            let py = y as f32 + 0.5;
            let cx = px.clamp(x0 as f32 + radius, x1 as f32 - radius);
            let cy = py.clamp(y0 as f32 + radius, y1 as f32 - radius);
            let dist = ((px - cx).powi(2) + (py - cy).powi(2)).sqrt() - radius;
            if dist >= 1.0 {
                continue;
            }
            let idx = (y as usize * width + x as usize) * 4;
            if dist <= -1.0 {
                buf[idx..idx + 4].copy_from_slice(&rgb_to_bgra(color, 255));
            } else {
                let alpha = (255.0 * (1.0 - smoothstep(-1.0, 1.0, dist))).round() as u8;
                let premul = |c: u8| ((c as u32 * alpha as u32 + 127) / 255) as u8;
                buf[idx..idx + 4].copy_from_slice(&rgb_to_bgra((premul(color.0), premul(color.1), premul(color.2)), alpha));
            }
        }
    }
}

/// Same rounded-rect antialiasing as `fill_rounded_rect`, but blends its
/// edge pixels against whatever is *already* in `buf` instead of assuming
/// a transparent canvas - needed for a menu row's hover highlight, which
/// paints on top of the panel's own already-opaque background fill.
/// `fill_rounded_rect` itself can't be reused there: its edge pixels
/// premultiply toward black (correct on a blank canvas, where "not fully
/// covered" means "let the transparent backdrop show through"), which
/// would show up as a visible dark seam around every rounded hover chip
/// sitting on top of an opaque panel instead of a clean blend into it.
#[allow(clippy::too_many_arguments)]
fn fill_rounded_rect_over(buf: &mut [u8], width: usize, height: usize, x0: i32, y0: i32, x1: i32, y1: i32, radius: f32, color: (u8, u8, u8)) {
    let (w, h) = (width as i32, height as i32);
    let radius = radius.min((x1 - x0) as f32 / 2.0).min((y1 - y0) as f32 / 2.0).max(0.0);
    let new_px = rgb_to_bgra(color, 255);
    for y in y0.max(0)..y1.min(h) {
        for x in x0.max(0)..x1.min(w) {
            let px = x as f32 + 0.5;
            let py = y as f32 + 0.5;
            let cx = px.clamp(x0 as f32 + radius, x1 as f32 - radius);
            let cy = py.clamp(y0 as f32 + radius, y1 as f32 - radius);
            let dist = ((px - cx).powi(2) + (py - cy).powi(2)).sqrt() - radius;
            if dist >= 1.0 {
                continue;
            }
            let idx = (y as usize * width + x as usize) * 4;
            if dist <= -1.0 {
                buf[idx..idx + 4].copy_from_slice(&new_px);
            } else {
                let t = 1.0 - smoothstep(-1.0, 1.0, dist);
                let existing = &buf[idx..idx + 4];
                let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8;
                let blended = (lerp(existing[0], new_px[0]), lerp(existing[1], new_px[1]), lerp(existing[2], new_px[2]), lerp(existing[3], new_px[3]));
                buf[idx..idx + 4].copy_from_slice(&[blended.0, blended.1, blended.2, blended.3]);
            }
        }
    }
}

fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32 {
    let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
    t * t * (3.0 - 2.0 * t)
}

fn draw_folder_glyph(buf: &mut [u8], width: usize, height: usize, b: (i32, i32, i32, i32), top: (u8, u8, u8), bottom: (u8, u8, u8), border: (u8, u8, u8)) {
    let (x0, y0, x1, y1) = b;
    let tab_w = (x1 - x0) * 2 / 5;
    let tab_h = 6;
    fill_rounded_rect(buf, width, height, x0, y0, x0 + tab_w, y0 + tab_h + 2, 2.0, top, bottom);
    fill_rounded_rect(buf, width, height, x0, y0 + tab_h, x1, y1, 4.0, top, bottom);
    fill_rect(buf, width, height, x0 + 1, y0 + tab_h, x1 - 1, y0 + tab_h + 1, border, 255);
}

fn draw_computer_glyph(buf: &mut [u8], width: usize, height: usize, b: (i32, i32, i32, i32), top: (u8, u8, u8), bottom: (u8, u8, u8), border: (u8, u8, u8)) {
    let (x0, y0, x1, y1) = b;
    let screen_bottom = y0 + (y1 - y0) * 3 / 4;
    fill_rounded_rect(buf, width, height, x0, y0, x1, screen_bottom, 3.0, border, border);
    fill_rounded_rect(buf, width, height, x0 + 2, y0 + 2, x1 - 2, screen_bottom - 2, 1.5, top, bottom);
    let stand_w = (x1 - x0) / 4;
    let stand_x0 = x0 + (x1 - x0 - stand_w) / 2;
    fill_rect(buf, width, height, stand_x0, screen_bottom, stand_x0 + stand_w, y1 - 2, border, 255);
    fill_rounded_rect(buf, width, height, x0 + 2, y1 - 3, x1 - 2, y1, 1.5, border, border);
}

fn draw_trash_glyph(buf: &mut [u8], width: usize, height: usize, b: (i32, i32, i32, i32), top: (u8, u8, u8), bottom: (u8, u8, u8), border: (u8, u8, u8)) {
    let (x0, y0, x1, y1) = b;
    let lid_h = 4;
    fill_rounded_rect(buf, width, height, x0, y0, x1, y0 + lid_h, 1.5, border, border);
    let handle_w = (x1 - x0) / 3;
    let handle_x0 = x0 + (x1 - x0 - handle_w) / 2;
    fill_rect(buf, width, height, handle_x0, y0 - 3, handle_x0 + handle_w, y0, border, 255);
    let body_x0 = x0 + 2;
    let body_x1 = x1 - 2;
    fill_rounded_rect(buf, width, height, body_x0, y0 + lid_h, body_x1, y1, 3.0, top, bottom);
    // Three vertical ridge lines, the classic trash-can silhouette detail.
    let ridge_w = 2;
    for i in 0..3 {
        let rx = body_x0 + (body_x1 - body_x0) * (i * 2 + 1) / 6;
        fill_rect(buf, width, height, rx, y0 + lid_h + 3, rx + ridge_w, y1 - 3, border, 255);
    }
}

fn draw_file_glyph(buf: &mut [u8], width: usize, height: usize, b: (i32, i32, i32, i32), top: (u8, u8, u8), bottom: (u8, u8, u8), border: (u8, u8, u8)) {
    let (x0, y0, x1, y1) = b;
    fill_rounded_rect(buf, width, height, x0, y0, x1, y1, 3.0, border, border);
    fill_rounded_rect(buf, width, height, x0 + 2, y0 + 2, x1 - 2, y1 - 2, 2.0, top, bottom);
    // A header strip near the top, the same "document" cue `draw_folder_
    // glyph`'s tab gives a folder - deliberately no folded-corner detail,
    // which would need a diagonal (not axis-aligned) fill this file's other
    // glyphs never need.
    fill_rect(buf, width, height, x0 + 4, y0 + 5, x1 - 4, y0 + 8, border, 255);
}

fn draw_home_glyph(buf: &mut [u8], width: usize, height: usize, b: (i32, i32, i32, i32), top: (u8, u8, u8), bottom: (u8, u8, u8), border: (u8, u8, u8)) {
    let (x0, y0, x1, y1) = b;
    let mid_x = (x0 + x1) / 2;
    let roof_y = y0 + (y1 - y0) / 3;
    // A simple triangular roof built from shrinking horizontal strips
    // (this file's only axis-aligned primitives are filled rects) rather
    // than a real diagonal line - coarse at this size, but reads clearly
    // as a roof over the body rect below it.
    let steps = (roof_y - y0).max(1);
    for i in 0..steps {
        let y = y0 + i;
        let inset = (i * (mid_x - x0)) / steps;
        fill_rect(buf, width, height, mid_x - inset - 1, y, mid_x + inset + 1, y + 1, border, 255);
    }
    fill_rounded_rect(buf, width, height, x0 + 2, roof_y, x1 - 2, y1, 2.0, top, bottom);
    fill_rect(buf, width, height, x0 + 2, roof_y, x1 - 2, roof_y + 2, border, 255);
    let door_w = (x1 - x0) / 4;
    let door_x0 = mid_x - door_w / 2;
    fill_rounded_rect(buf, width, height, door_x0, y1 - 10, door_x0 + door_w, y1, 1.5, border, border);
}

#[cfg(test)]
mod tests;