saudade 0.4.0

Classic looking retained-mode, cross-platform Rust GUI library
Documentation
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
//! Window chrome for framed screenshots.
//!
//! Saudade itself never draws its own title bar or frame — on a real desktop
//! that is the window manager's job. For automated screenshots, though, it is
//! often nice to capture a window the way a user actually sees it: sitting on
//! the desktop, inside a title bar and frame, with a drop shadow. This module
//! draws that chrome around a rendered widget tree.
//!
//! The look reproduces the default rendering style of **Canoe** (the Win 3.1
//! styled stacking window manager saudade is usually paired with): a teal
//! desktop, a soft drop shadow, a navy active title bar, light-gray beveled
//! window controls, and a black multi-layer border. Windows are always drawn
//! in their *active* (focused) state.
//!
//! The caller picks one of three frame styles via [`WindowFrame`], mirroring
//! the three Canoe paints for a window:
//!
//! * [`WindowFrame::Resizable`] — minimize + maximize buttons and the full
//!   multi-layer resize border.
//! * [`WindowFrame::Fixed`] — a minimize button only, and a single 1px outline
//!   instead of the chunky resize border.
//! * [`WindowFrame::Dialog`] — no minimize/maximize buttons, and the border's
//!   bulk layer takes the title-bar color so the frame reads as one with the
//!   title.
//!
//! Drive it through [`MockBackend::render_framed`](crate::mock::MockBackend::render_framed).

use crate::geometry::{Color, Rect, Size};
use crate::include_svg;
use crate::painter::Painter;
use crate::svg::SvgImage;

// --- Canoe default palette (its `UiConfig::default`), in saudade colors. ---

/// The teal Canoe desktop.
const DESKTOP_BACKGROUND: Color = Color::rgb(0x00, 0x80, 0x80);
/// Active title-bar fill (Win 3.1 navy).
const TITLEBAR_BG: Color = Color::NAVY;
/// Active title-bar text.
const TITLEBAR_TEXT: Color = Color::WHITE;
/// Outer/inner thin border layers, the separator, and the button dividers.
const BORDER_DARK: Color = Color::BLACK;
/// The bulk middle border layer for resizable / fixed windows.
const BORDER_MID: Color = Color::LIGHT_GRAY;
/// Window-control button face and its 3D bevel.
const BUTTON_BG: Color = Color::LIGHT_GRAY;
const BUTTON_HIGHLIGHT: Color = Color::WHITE;
const BUTTON_SHADOW: Color = Color::MID_GRAY;

// Title-bar control glyphs, baked from the same SVGs Canoe ships: a beveled
// horizontal bar for the close box, and filled triangles for minimize /
// maximize. The full 16-unit viewBox is preserved (no `crop`), so the marks
// keep the centered padding Canoe rasterizes them with.
const CLOSE_ICON: SvgImage = include_svg!("assets/chrome/close.svg");
const MINIMIZE_ICON: SvgImage = include_svg!("assets/chrome/minimize.svg");
const MAXIMIZE_ICON: SvgImage = include_svg!("assets/chrome/maximize.svg");

/// Soft shadow tint: black at ~20% (`0x00000033`). Drawn as pure black whose
/// alpha is `SHADOW_ALPHA` scaled by the falloff curve.
const SHADOW_ALPHA: f32 = 0x33 as f32;

// --- Canoe default metrics, in logical pixels (its `UiConfig::default`). ---

/// Point size of the title-bar text (and the basis for the bar's height).
const CHROME_FONT_SIZE: f32 = 12.0;
/// Resize-border thickness for resizable / dialog windows.
const BORDER_NORMAL: i32 = 4;
/// The 1px outline a fixed-size window collapses its border to.
const BORDER_FIXED: i32 = 1;
/// Active-window soft-shadow extent.
const SHADOW_SIZE: i32 = 20;
/// Default teal margin between the window (with its shadow) and the image edge.
const DEFAULT_MARGIN: i32 = 40;
/// Padding on each side of the title text within its slot.
const TITLE_PADDING: i32 = 6;

/// Title-bar height in logical pixels — Canoe's `0.75·font_size` rounded, then
/// doubled plus one so it is always odd (a clean center row).
fn titlebar_height() -> i32 {
    let base = (0.75 * CHROME_FONT_SIZE).round() as i32;
    (base * 2 + 1).max(1)
}

/// Square title-bar button / glyph cell size in logical pixels, derived from
/// the bar height the same way Canoe sizes its control icons.
fn icon_size() -> i32 {
    let tbh = titlebar_height();
    (tbh - 4).clamp(6, tbh.max(1))
}

/// Which Canoe frame style to draw around the captured window.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WindowFrame {
    /// A standard resizable top-level window: minimize **and** maximize
    /// buttons, plus the full multi-layer resize border.
    Resizable,
    /// A non-resizable window: a minimize button only (no maximize), and the
    /// border collapsed to a single 1px outline (no resize border).
    Fixed,
    /// A dialog / child window: no minimize or maximize buttons, and the
    /// border's bulk layer takes the title-bar color so the frame reads as a
    /// continuation of the title.
    Dialog,
}

impl WindowFrame {
    /// Resize-border thickness this style reserves (logical px).
    fn border_width(self) -> i32 {
        match self {
            WindowFrame::Fixed => BORDER_FIXED,
            _ => BORDER_NORMAL,
        }
    }

    fn shows_minimize(self) -> bool {
        matches!(self, WindowFrame::Resizable | WindowFrame::Fixed)
    }

    fn shows_maximize(self) -> bool {
        matches!(self, WindowFrame::Resizable)
    }

    /// The bulk middle border layer color. Dialogs blend it into the title bar.
    fn mid_color(self) -> Color {
        match self {
            WindowFrame::Dialog => TITLEBAR_BG,
            _ => BORDER_MID,
        }
    }
}

/// How to wrap a screenshot in window chrome. Construct one of these and hand it
/// to [`MockBackend::render_framed`](crate::mock::MockBackend::render_framed).
///
/// The defaults reproduce Canoe's default desktop rendering; the builder
/// methods exist for the occasional screenshot that wants a different desktop
/// color or more / less breathing room.
///
/// ```no_run
/// use saudade::*;
/// use saudade::mock::MockBackend;
///
/// let mut root = Container::new(220, 64)
///     .with_background(Color::WHITE)
///     .add(Label::new(Rect::new(16, 24, 200, 16), "Ready."));
///
/// let snap = MockBackend::new(220, 64)
///     .render_framed(&mut root, &WindowChrome::resizable("My App"));
/// std::fs::write("shot.png", snap.to_png()).unwrap();
/// ```
#[derive(Clone, Debug)]
pub struct WindowChrome {
    title: String,
    frame: WindowFrame,
    desktop_background: Color,
    margin: i32,
}

impl WindowChrome {
    /// Chrome with the given window `title` and frame style, on the default
    /// teal desktop with the default margin.
    pub fn new(title: impl Into<String>, frame: WindowFrame) -> Self {
        Self {
            title: title.into(),
            frame,
            desktop_background: DESKTOP_BACKGROUND,
            margin: DEFAULT_MARGIN,
        }
    }

    /// A resizable window (minimize + maximize buttons, full resize border).
    pub fn resizable(title: impl Into<String>) -> Self {
        Self::new(title, WindowFrame::Resizable)
    }

    /// A fixed-size window (minimize button only, 1px outline border).
    pub fn fixed(title: impl Into<String>) -> Self {
        Self::new(title, WindowFrame::Fixed)
    }

    /// A dialog window (no minimize / maximize buttons, dialog-style frame).
    pub fn dialog(title: impl Into<String>) -> Self {
        Self::new(title, WindowFrame::Dialog)
    }

    /// Override the teal desktop backdrop.
    pub fn with_desktop_background(mut self, color: Color) -> Self {
        self.desktop_background = color;
        self
    }

    /// Override the margin (logical px) of desktop around the window. The drop
    /// shadow is drawn within this margin, so very small values clip it.
    pub fn with_margin(mut self, margin: i32) -> Self {
        self.margin = margin.max(0);
        self
    }

    /// Whether a window in this frame style paints the desktop background
    /// pattern behind its content. Mirrors the live backend: a regular
    /// top-level window (resizable / fixed) honors the pattern, while a dialog
    /// — rendered in the live runtime's separate popup/dialog pass — stays
    /// plain.
    pub(crate) fn paints_background_pattern(&self) -> bool {
        self.frame != WindowFrame::Dialog
    }
}

/// Physical-pixel geometry of a framed render, derived from the content size,
/// scale, and chrome. All rectangles are in the framed buffer's coordinate
/// space (origin top-left).
pub(crate) struct Metrics {
    /// Physical scale the content was rendered at; chrome text / glyphs match it.
    scale: f32,
    /// Full framed buffer size.
    pub buffer: Size,
    /// The window frame (border + title bar + content), shadow excluded.
    frame: Rect,
    /// The title-bar strip (inside the border, above the separator).
    titlebar: Rect,
    /// Where the content blits in: top-left and (== the rendered content) size.
    pub content: Rect,
    /// Resize-border thickness.
    bw: i32,
    /// Separator / thin-line / divider thickness (one logical px).
    unit: i32,
    /// Soft-shadow extent.
    shadow: i32,
}

/// Compute the framed geometry for a `content_phys`-sized (physical px) render
/// at `scale`. Chrome metrics are taken in logical px and scaled here the same
/// way the painter scales everything else, so the frame lines up with the
/// content at any DPI.
pub(crate) fn metrics(content_phys: Size, scale: f32, chrome: &WindowChrome) -> Metrics {
    let scale = scale.max(0.01);
    let px = |logical: i32| (logical as f32 * scale).round() as i32;

    let bw = px(chrome.frame.border_width());
    let tbh = px(titlebar_height());
    let unit = px(1).max(1);
    let margin = px(chrome.margin);
    let shadow = px(SHADOW_SIZE);

    let cw = content_phys.w.max(1);
    let ch = content_phys.h.max(1);

    // Border + title bar + 1px separator + content + border.
    let frame_w = cw + bw * 2;
    let frame_h = ch + tbh + unit + bw * 2;

    let buffer = Size::new(frame_w + margin * 2, frame_h + margin * 2);
    let frame = Rect::new(margin, margin, frame_w, frame_h);
    let titlebar = Rect::new(frame.x + bw, frame.y + bw, cw, tbh);
    let content = Rect::new(frame.x + bw, frame.y + bw + tbh + unit, cw, ch);

    Metrics {
        scale,
        buffer,
        frame,
        titlebar,
        content,
        bw,
        unit,
        shadow,
    }
}

/// Paint the desktop, shadow, border, title bar, buttons, and title into the
/// painter (which must cover [`Metrics::buffer`] at scale 1.0). The content
/// area is left untouched for the caller to blit the rendered widget tree into.
pub(crate) fn paint(painter: &mut Painter, m: &Metrics, chrome: &WindowChrome) {
    // Desktop backdrop, then the soft drop shadow over it.
    painter.fill(chrome.desktop_background);
    draw_shadow(painter, m);

    // Border ring(s). The interior is filled here and then mostly overdrawn by
    // the title bar / separator / content, leaving just the border layers.
    painter.fill_rect(m.frame, BORDER_DARK);
    if chrome.frame != WindowFrame::Fixed {
        let outer = m.unit;
        let inner = m.unit;
        let mid = (m.bw - outer - inner).max(0);
        painter.fill_rect(m.frame.inset(outer), chrome.frame.mid_color());
        painter.fill_rect(m.frame.inset(outer + mid), BORDER_DARK);
    }

    // Title bar, then the black separator line beneath it.
    painter.fill_rect(m.titlebar, TITLEBAR_BG);
    painter.fill_rect(
        Rect::new(m.titlebar.x, m.titlebar.bottom(), m.titlebar.w, m.unit),
        BORDER_DARK,
    );

    draw_buttons_and_title(painter, m, chrome);
}

/// The three title-bar button slots, laid out left-to-right then right-to-left
/// exactly as Canoe positions them: a close box at the far left, and the
/// maximize / minimize buttons hugging the right edge.
struct ButtonSlots {
    close: Rect,
    minimize: Option<Rect>,
    maximize: Option<Rect>,
}

fn button_slots(m: &Metrics, frame: WindowFrame) -> ButtonSlots {
    let size = m.titlebar.h; // square buttons, one bar tall
    let y = m.titlebar.y;
    let close = Rect::new(m.titlebar.x, y, size, size);

    let gap = m.unit.max(1);
    let right_edge = m.titlebar.right();
    let mut next_right = right_edge - size;

    let maximize = frame.shows_maximize().then(|| {
        let r = Rect::new(next_right, y, size, size);
        next_right -= size + gap;
        r
    });
    let minimize = frame
        .shows_minimize()
        .then(|| Rect::new(next_right, y, size, size));

    ButtonSlots {
        close,
        minimize,
        maximize,
    }
}

fn draw_buttons_and_title(painter: &mut Painter, m: &Metrics, chrome: &WindowChrome) {
    let slots = button_slots(m, chrome.frame);
    let unit = m.unit.max(1);
    let icon = (icon_size() as f32 * m.scale).round() as i32;

    // Close box: a flat gray face with a black divider on its right, holding the
    // beveled Win 3.1 control-menu bar.
    painter.fill_rect(slots.close, BUTTON_BG);
    draw_divider(painter, slots.close.right(), m, unit);
    CLOSE_ICON.draw(painter, icon_box(slots.close, icon));

    if let Some(min) = slots.minimize {
        draw_divider(painter, min.x - unit, m, unit);
        draw_raised_button(painter, min, unit);
        MINIMIZE_ICON.draw(painter, icon_box(min, icon));
    }
    if let Some(max) = slots.maximize {
        draw_divider(painter, max.x - unit, m, unit);
        draw_raised_button(painter, max, unit);
        MAXIMIZE_ICON.draw(painter, icon_box(max, icon));
    }

    draw_title(painter, m, &slots, chrome);
}

/// The centered `size`×`size` cell for a glyph inside a button.
fn icon_box(button: Rect, size: i32) -> Rect {
    Rect::new(
        button.x + (button.w - size) / 2,
        button.y + (button.h - size) / 2,
        size,
        size,
    )
}

/// A vertical black divider line `unit` wide spanning the title bar height.
fn draw_divider(painter: &mut Painter, x: i32, m: &Metrics, unit: i32) {
    if x < m.titlebar.x {
        return;
    }
    painter.fill_rect(Rect::new(x, m.titlebar.y, unit, m.titlebar.h), BORDER_DARK);
}

/// A raised 3D button: gray face, white highlight on the top/left, a doubled
/// gray shadow on the bottom/right — the bevel Canoe gives its min/max buttons.
fn draw_raised_button(painter: &mut Painter, r: Rect, unit: i32) {
    painter.fill_rect(r, BUTTON_BG);
    // Highlight: top and left edges.
    painter.fill_rect(Rect::new(r.x, r.y, r.w, unit), BUTTON_HIGHLIGHT);
    painter.fill_rect(Rect::new(r.x, r.y, unit, r.h), BUTTON_HIGHLIGHT);
    // Inner shadow.
    if r.w >= 3 * unit && r.h >= 3 * unit {
        painter.fill_rect(
            Rect::new(r.x + unit, r.bottom() - 2 * unit, r.w - 2 * unit, unit),
            BUTTON_SHADOW,
        );
        painter.fill_rect(
            Rect::new(r.right() - 2 * unit, r.y + unit, unit, r.h - 2 * unit),
            BUTTON_SHADOW,
        );
    }
    // Outer shadow: bottom and right edges.
    painter.fill_rect(Rect::new(r.x, r.bottom() - unit, r.w, unit), BUTTON_SHADOW);
    painter.fill_rect(Rect::new(r.right() - unit, r.y, unit, r.h), BUTTON_SHADOW);
}

/// Draw the title text: left-aligned after the close box, vertically centered in
/// the bar, and clipped to the space before the right-hand buttons.
fn draw_title(painter: &mut Painter, m: &Metrics, slots: &ButtonSlots, chrome: &WindowChrome) {
    if chrome.title.is_empty() || painter.font().is_none() {
        return;
    }
    let pad = (TITLE_PADDING as f32 * m.scale).round() as i32;
    let gap = m.unit.max(1);

    let text_start = slots.close.right() + gap + pad;
    let right_x = slots
        .minimize
        .or(slots.maximize)
        .map(|r| r.x)
        .unwrap_or(m.titlebar.right());
    let text_end = right_x - gap - pad;
    let text_w = text_end - text_start;
    if text_w <= 0 {
        return;
    }

    let font_px = CHROME_FONT_SIZE * m.scale;
    let text_h = painter.measure_text(&chrome.title, font_px).h;
    let ty = m.titlebar.y + ((m.titlebar.h - text_h) / 2).max(0);

    let clip = painter.push_clip(Rect::new(text_start, m.titlebar.y, text_w, m.titlebar.h));
    painter.text(text_start, ty, &chrome.title, font_px, TITLEBAR_TEXT);
    painter.restore_clip(clip);
}

/// Draw the soft, rounded drop shadow over the desktop. Replicates Canoe's
/// quadratic-falloff rounded-rect shadow with the falloff curve
/// `alpha = base · (1 − d/s)²` out to `s`.
///
/// The occluder that casts the shadow is the window frame with its *top* edge
/// pushed down by half the shadow extent while its bottom and sides stay on the
/// frame (Canoe's `frame_height − shadow_size/2` height plus a `shadow_size/2`
/// downward shift). So the shadow hugs the bottom and side borders with no gap,
/// while the top stays light — the look of a window lit from above.
fn draw_shadow(painter: &mut Painter, m: &Metrics) {
    let s = m.shadow;
    if s <= 0 {
        return;
    }
    let s_f = s as f32;
    let shift = s / 2;
    // Occluder: frame top moved down by `shift`, bottom/left/right unchanged.
    let occ_x = m.frame.x;
    let occ_y = m.frame.y + shift;
    let occ_w = m.frame.w;
    let occ_h = (m.frame.h - shift).max(1);

    let cx = occ_x as f32 + occ_w as f32 / 2.0;
    let cy = occ_y as f32 + occ_h as f32 / 2.0;
    let hx = occ_w as f32 / 2.0;
    let hy = occ_h as f32 / 2.0;
    let r = (shift as f32).clamp(0.0, hx.min(hy).max(0.0));

    // Only the band around the occluder can carry shadow; scan that region.
    let x0 = (occ_x - s - 1).max(0);
    let x1 = (occ_x + occ_w + s + 1).min(m.buffer.w);
    let y0 = (occ_y - s - 1).max(0);
    let y1 = (occ_y + occ_h + s + 1).min(m.buffer.h);

    for py in y0..y1 {
        for px in x0..x1 {
            let dx = (px as f32 + 0.5) - cx;
            let dy = (py as f32 + 0.5) - cy;
            // Signed distance to the rounded rect.
            let qx = dx.abs() - (hx - r);
            let qy = dy.abs() - (hy - r);
            let outside = (qx.max(0.0).powi(2) + qy.max(0.0).powi(2)).sqrt();
            let inside = qx.max(qy).min(0.0);
            let dist = outside + inside - r;
            if dist <= 0.0 || dist > s_f {
                continue;
            }
            let falloff = 1.0 - dist / s_f;
            let alpha = (SHADOW_ALPHA * falloff * falloff).round();
            if alpha <= 0.0 {
                continue;
            }
            painter.blend_pixel_phys(px, py, Color::BLACK, alpha as u8);
        }
    }
}