srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/wayland/src/decoration.rs
blob: bbd68173bdadf611a65a29e095c3d38d349ce91e (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
//! 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::rgb_to_bgra;
pub(crate) use corners::{round_bottom_corners, round_top_corners};
pub(crate) use font::{blit_glyph, find_system_font, FONT_PIXELS, TEXT_LEFT_PADDING};
pub use shadow::{shadow_bitmap, shadow_rect};
pub(crate) use shadow::SHADOW_MAX_ALPHA;
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.
///
/// 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 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)]
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)) -> 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 = ((width as i32 - 40) / 2, 8, (width as i32 + 40) / 2, 44);
    let border = color::darken(icon_color);
    match kind {
        IconKind::Home => draw_home_glyph(&mut buf, width, height, glyph_box, icon_color, border),
        IconKind::Computer => draw_computer_glyph(&mut buf, width, height, glyph_box, icon_color, border),
        IconKind::Trash => draw_trash_glyph(&mut buf, width, height, glyph_box, icon_color, border),
        IconKind::Folder => draw_folder_glyph(&mut buf, width, height, glyph_box, icon_color, border),
        IconKind::File => draw_file_glyph(&mut buf, width, height, glyph_box, icon_color, border),
    }

    let label_top = 50i32;
    if selected {
        fill_rect(&mut buf, width, height, 2, label_top, width as i32 - 2, height as i32 - 2, selected_bg, 255);
    }
    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;
        }
        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;
                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)]
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)]
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));
        }
    }
}

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

fn draw_computer_glyph(buf: &mut [u8], width: usize, height: usize, b: (i32, i32, i32, i32), fill: (u8, u8, u8), border: (u8, u8, u8)) {
    let (x0, y0, x1, y1) = b;
    let screen_bottom = y0 + (y1 - y0) * 3 / 4;
    fill_rect(buf, width, height, x0, y0, x1, screen_bottom, border, 255);
    fill_rect(buf, width, height, x0 + 2, y0 + 2, x1 - 2, screen_bottom - 2, fill, 255);
    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_rect(buf, width, height, x0 + 2, y1 - 2, x1 - 2, y1, border, 255);
}

fn draw_trash_glyph(buf: &mut [u8], width: usize, height: usize, b: (i32, i32, i32, i32), fill: (u8, u8, u8), border: (u8, u8, u8)) {
    let (x0, y0, x1, y1) = b;
    let lid_h = 4;
    fill_rect(buf, width, height, x0, y0, x1, y0 + lid_h, border, 255);
    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_rect(buf, width, height, body_x0, y0 + lid_h, body_x1, y1, fill, 255);
    // 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), fill: (u8, u8, u8), border: (u8, u8, u8)) {
    let (x0, y0, x1, y1) = b;
    fill_rect(buf, width, height, x0, y0, x1, y1, border, 255);
    fill_rect(buf, width, height, x0 + 2, y0 + 2, x1 - 2, y1 - 2, fill, 255);
    // 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), fill: (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 primitive is a filled rect) 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_rect(buf, width, height, x0 + 2, roof_y, x1 - 2, y1, fill, 255);
    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_rect(buf, width, height, door_x0, y1 - 10, door_x0 + door_w, y1, border, 255);
}

#[cfg(test)]
mod tests;