1
//! Titlebar widget for custom window chrome (CSD and title-only modes).
2
//!
3
//! Key type: [`Titlebar`]
4

            
5
use azul_core::{
6
    dom::{Dom, DomVec, IdOrClass, IdOrClass::Class, IdOrClass::Id, IdOrClassVec},
7
    refany::RefAny,
8
};
9
#[allow(clippy::wildcard_imports)] // widget/render module pulls in the css property/value types it builds with
10
use azul_css::{
11
    dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec},
12
    props::{
13
        basic::{
14
            color::ColorU,
15
            font::{StyleFontFamily, StyleFontFamilyVec},
16
            *,
17
        },
18
        layout::*,
19
        property::{CssProperty, *},
20
        style::*,
21
    },
22
    system::{SystemFontType, SystemStyle, TitlebarButtonSide, TitlebarButtons, TitlebarMetrics},
23
    *,
24
};
25

            
26
// ── Compile-time defaults (used when no SystemStyle is available) ─────────
27

            
28
// Verified: macOS 11 Big Sur – macOS 15 Sequoia (2020–2025)
29
#[cfg(target_os = "macos")]
30
const DEFAULT_TITLEBAR_HEIGHT: f32 = 28.0;
31
#[cfg(target_os = "windows")]
32
const DEFAULT_TITLEBAR_HEIGHT: f32 = 32.0;
33
#[cfg(target_os = "linux")]
34
const DEFAULT_TITLEBAR_HEIGHT: f32 = 30.0;
35
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
36
const DEFAULT_TITLEBAR_HEIGHT: f32 = 32.0;
37

            
38
#[cfg(target_os = "macos")]
39
const DEFAULT_TITLE_FONT_SIZE: f32 = 13.0;
40
#[cfg(target_os = "windows")]
41
const DEFAULT_TITLE_FONT_SIZE: f32 = 12.0;
42
#[cfg(target_os = "linux")]
43
const DEFAULT_TITLE_FONT_SIZE: f32 = 13.0;
44
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
45
const DEFAULT_TITLE_FONT_SIZE: f32 = 13.0;
46

            
47
// Verified: macOS 11–15 traffic-light geometry = 78px including gaps
48
#[cfg(target_os = "macos")]
49
const DEFAULT_BUTTON_AREA_WIDTH: f32 = 78.0;
50
// Windows 10/11: 3 buttons x 46px = 138px
51
#[cfg(target_os = "windows")]
52
const DEFAULT_BUTTON_AREA_WIDTH: f32 = 138.0;
53
#[cfg(target_os = "linux")]
54
const DEFAULT_BUTTON_AREA_WIDTH: f32 = 100.0;
55
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
56
const DEFAULT_BUTTON_AREA_WIDTH: f32 = 100.0;
57

            
58
// macOS: traffic lights on the left.  All others: right.
59
#[cfg(target_os = "macos")]
60
const DEFAULT_BUTTON_SIDE_LEFT: bool = true;
61
#[cfg(not(target_os = "macos"))]
62
const DEFAULT_BUTTON_SIDE_LEFT: bool = false;
63

            
64
// Default title text color for light / dark fallback
65
const DEFAULT_TITLE_COLOR_LIGHT: ColorU = ColorU { r: 76, g: 76, b: 76, a: 255 };  // #4c4c4c
66
const DEFAULT_TITLE_COLOR_DARK: ColorU = ColorU { r: 229, g: 229, b: 229, a: 255 }; // #e5e5e5
67

            
68
// ── Titlebar ─────────────────────────────────────────────────────────────
69

            
70
/// A titlebar widget with optional close / minimize / maximize
71
/// buttons, drag-to-move, and double-click-to-maximize.
72
///
73
/// # Two modes
74
///
75
/// 1. **Title-only** ([`Titlebar::dom`], the default for
76
///    `WindowDecorations::NoTitleAutoInject`):
77
///    The OS still draws the native window-control buttons (traffic lights on
78
///    macOS, caption buttons on Windows).  The titlebar reserves
79
///    `padding_left` / `padding_right` so the title text doesn't overlap them.
80
///
81
/// 2. **Full CSD** ([`Titlebar::dom_with_buttons`], used when
82
///    `WindowDecorations::None` + `has_decorations`):
83
///    The titlebar renders its own close / minimize / maximize buttons as
84
///    regular DOM nodes.  Each button carries a plain `MouseDown` callback
85
///    that calls `CallbackInfo::modify_window_state()` - exactly the same
86
///    mechanism used for window dragging.  No special event-system hooks.
87
///
88
/// Window-control buttons use `Dom::create_icon("close")` etc. so that
89
/// icons are resolved through the icon provider system (Material Icons
90
/// by default) and can be swapped out by registering a different icon pack.
91
///
92
/// # Button layout
93
///
94
/// `button_side` controls where the buttons appear:
95
/// - `Left` - macOS traffic-light style (buttons before title)
96
/// - `Right` - Windows / Linux style (title then buttons)
97
///
98
/// # Styling
99
///
100
/// The DOM uses CSS classes `.csd-titlebar`, `.csd-title`, `.csd-buttons`,
101
/// `.csd-button`, `.csd-close`, `.csd-minimize`, `.csd-maximize`.
102
/// These match the output of `SystemStyle::create_csd_stylesheet()`.
103
#[derive(Debug, Clone, PartialEq, PartialOrd)]
104
#[repr(C)]
105
pub struct Titlebar {
106
    /// The title text to display.
107
    pub title: AzString,
108
    /// Height of the titlebar in CSS pixels.
109
    pub height: f32,
110
    /// Font size for the title text in CSS pixels.
111
    pub font_size: f32,
112
    /// Extra padding on the **left** side (px).
113
    pub padding_left: f32,
114
    /// Extra padding on the **right** side (px).
115
    pub padding_right: f32,
116
    /// Title text color (resolved from SystemStyle.colors.text or platform default).
117
    pub title_color: ColorU,
118
}
119

            
120
impl Titlebar {
121
    /// Create a titlebar with compile-time platform defaults.
122
    ///
123
    /// Use [`Titlebar::from_system_style`] when you have a
124
    /// `SystemStyle` available for pixel-perfect metrics.
125
    #[inline]
126
273
    #[must_use] pub fn new(title: AzString) -> Self {
127
        // Equal padding on both sides keeps text-align:center at the window midpoint.
128
        // The button-side half prevents overlap; the opposite half balances it.
129
273
        let half = DEFAULT_BUTTON_AREA_WIDTH / 2.0;
130
273
        let (padding_left, padding_right) = (half, half);
131
273
        Self {
132
273
            title,
133
273
            height: DEFAULT_TITLEBAR_HEIGHT,
134
273
            font_size: DEFAULT_TITLE_FONT_SIZE,
135
273
            padding_left,
136
273
            padding_right,
137
273
            title_color: DEFAULT_TITLE_COLOR_LIGHT,
138
273
        }
139
273
    }
140

            
141
    /// FFI-compatible alias for [`Titlebar::new`].
142
    #[inline]
143
12
    #[must_use] pub fn create(title: AzString) -> Self {
144
12
        Self::new(title)
145
12
    }
146

            
147
    /// Create a titlebar with a custom height.
148
    #[inline]
149
21
    #[must_use] pub fn with_height(title: AzString, height: f32) -> Self {
150
21
        let mut tb = Self::new(title);
151
21
        tb.height = height;
152
21
        tb
153
21
    }
154

            
155
    /// Set the titlebar height.
156
    #[inline]
157
52
    pub const fn set_height(&mut self, height: f32) {
158
52
        self.height = height;
159
52
    }
160

            
161
    /// Set the title text.
162
    #[inline]
163
10
    pub fn set_title(&mut self, title: AzString) {
164
10
        self.title = title;
165
10
    }
166

            
167
    /// Swap this titlebar with a default instance, returning the old value.
168
    #[inline]
169
    #[must_use]
170
11
    pub fn swap_with_default(&mut self) -> Self {
171
11
        let mut s = Self::new(AzString::from_const_str(""));
172
11
        core::mem::swap(&mut s, self);
173
11
        s
174
11
    }
175

            
176
    /// Create from a live [`SystemStyle`] (for title-only mode, padding
177
    /// reserves space for OS-drawn buttons).
178
31
    #[must_use] pub fn from_system_style(title: AzString, system_style: &SystemStyle) -> Self {
179
31
        let tm = &system_style.metrics.titlebar;
180
31
        let height = tm.height.as_ref()
181
31
            .map_or(DEFAULT_TITLEBAR_HEIGHT, |pv| pv.to_pixels_internal(0.0, 0.0, 0.0));
182
31
        let font_size = tm.title_font_size
183
31
            .into_option()
184
31
            .unwrap_or(DEFAULT_TITLE_FONT_SIZE);
185
31
        let button_area = tm.button_area_width.as_ref()
186
31
            .map_or(DEFAULT_BUTTON_AREA_WIDTH, |pv| pv.to_pixels_internal(0.0, 0.0, 0.0));
187
31
        let safe_left = tm.safe_area.left.as_ref()
188
31
            .map_or(0.0, |pv| pv.to_pixels_internal(0.0, 0.0, 0.0));
189
31
        let safe_right = tm.safe_area.right.as_ref()
190
31
            .map_or(0.0, |pv| pv.to_pixels_internal(0.0, 0.0, 0.0));
191
        // Apply padding_horizontal from TitlebarMetrics
192
31
        let pad_h = tm.padding_horizontal.as_ref()
193
31
            .map_or(0.0, |pv| pv.to_pixels_internal(0.0, 0.0, 0.0));
194

            
195
        // Equal padding on both sides so text-align:center stays at the window midpoint.
196
        // button_area/2 on each side: the button-side half clears the traffic-lights/caption
197
        // buttons, the opposite half balances the centering offset.
198
31
        let half_btn = button_area / 2.0;
199
31
        let (padding_left, padding_right) = (
200
31
            half_btn + safe_left + pad_h,
201
31
            half_btn + safe_right + pad_h,
202
31
        );
203

            
204
        // Resolve title color from system style, with dark/light fallback
205
31
        let title_color = system_style.colors.text.into_option().unwrap_or(
206
31
            match system_style.theme {
207
3
                system::Theme::Dark => DEFAULT_TITLE_COLOR_DARK,
208
28
                system::Theme::Light => DEFAULT_TITLE_COLOR_LIGHT,
209
            }
210
        );
211

            
212
31
        Self { title, height, font_size, padding_left, padding_right, title_color }
213
31
    }
214

            
215
    /// Create from [`SystemStyle`] for **full CSD** mode (no padding - the
216
    /// buttons are rendered as DOM children).
217
12
    #[must_use] pub fn from_system_style_csd(title: AzString, system_style: &SystemStyle) -> Self {
218
12
        let tm = &system_style.metrics.titlebar;
219
12
        let height = tm.height.as_ref()
220
12
            .map_or(DEFAULT_TITLEBAR_HEIGHT, |pv| pv.to_pixels_internal(0.0, 0.0, 0.0));
221
12
        let font_size = tm.title_font_size
222
12
            .into_option()
223
12
            .unwrap_or(DEFAULT_TITLE_FONT_SIZE);
224
12
        let title_color = system_style.colors.text.into_option().unwrap_or(
225
12
            match system_style.theme {
226
1
                system::Theme::Dark => DEFAULT_TITLE_COLOR_DARK,
227
11
                system::Theme::Light => DEFAULT_TITLE_COLOR_LIGHT,
228
            }
229
        );
230
12
        Self { title, height, font_size, padding_left: 0.0, padding_right: 0.0, title_color }
231
12
    }
232

            
233
    /// Build inline CSS for the container div.
234
    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
235
146
    fn build_container_style(&self, show_buttons: bool) -> CssPropertyWithConditionsVec {
236
146
        let mut props = Vec::with_capacity(8);
237
146
        if show_buttons {
238
85
            // CSD mode: flex layout to place buttons + title side by side
239
85
            props.push(CssPropertyWithConditions::simple(
240
85
                CssProperty::const_display(LayoutDisplay::Flex),
241
85
            ));
242
85
            props.push(CssPropertyWithConditions::simple(
243
85
                CssProperty::const_flex_direction(LayoutFlexDirection::Row),
244
85
            ));
245
85
            props.push(CssPropertyWithConditions::simple(
246
85
                CssProperty::const_align_items(LayoutAlignItems::Center),
247
85
            ));
248
85
        } else {
249
61
            // Title-only mode: block layout — title fills width automatically.
250
61
            // Avoids flex-grow complexity; text centers via text-align.
251
61
            props.push(CssPropertyWithConditions::simple(
252
61
                CssProperty::const_display(LayoutDisplay::Block),
253
61
            ));
254
61
        }
255
146
        props.push(CssPropertyWithConditions::simple(
256
146
            CssProperty::const_height(LayoutHeight::const_px(self.height as isize)),
257
        ));
258
        // Titlebar should show grab cursor and prevent text selection
259
146
        props.push(CssPropertyWithConditions::simple(
260
146
            CssProperty::const_cursor(StyleCursor::Grab),
261
        ));
262
146
        props.push(CssPropertyWithConditions::simple(
263
146
            CssProperty::user_select(StyleUserSelect::None),
264
        ));
265
146
        if self.padding_left > 0.0 {
266
140
            props.push(CssPropertyWithConditions::simple(
267
140
                CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
268
140
                    self.padding_left as isize,
269
140
                )),
270
140
            ));
271
140
        }
272
146
        if self.padding_right > 0.0 {
273
139
            props.push(CssPropertyWithConditions::simple(
274
139
                CssProperty::const_padding_right(LayoutPaddingRight::const_px(
275
139
                    self.padding_right as isize,
276
139
                )),
277
139
            ));
278
139
        }
279
146
        CssPropertyWithConditionsVec::from_vec(props)
280
146
    }
281

            
282
    /// Build inline CSS for the title text node.
283
    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
284
156
    fn build_title_style(&self, show_buttons: bool) -> CssPropertyWithConditionsVec {
285
156
        let font_family = StyleFontFamilyVec::from_vec(vec![
286
156
            StyleFontFamily::SystemType(SystemFontType::TitleBold),
287
        ]);
288
156
        let mut props = Vec::with_capacity(10);
289
156
        props.push(CssPropertyWithConditions::simple(
290
156
            CssProperty::const_font_size(StyleFontSize::const_px(self.font_size as isize)),
291
        ));
292
156
        props.push(CssPropertyWithConditions::simple(
293
156
            CssProperty::const_font_family(font_family),
294
        ));
295
        // Use resolved title color from SystemStyle (adapts to dark mode)
296
156
        props.push(CssPropertyWithConditions::simple(
297
156
            CssProperty::const_text_color(StyleTextColor { inner: self.title_color }),
298
        ));
299
        // In CSD mode (flex container), title must grow to fill remaining space
300
156
        if show_buttons {
301
87
            props.push(CssPropertyWithConditions::simple(
302
87
                CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1)),
303
87
            ));
304
87
            props.push(CssPropertyWithConditions::simple(
305
87
                CssProperty::const_min_width(LayoutMinWidth::const_px(0)),
306
87
            ));
307
87
        }
308
156
        props.push(CssPropertyWithConditions::simple(
309
156
            CssProperty::const_text_align(StyleTextAlign::Center),
310
        ));
311
156
        props.push(CssPropertyWithConditions::simple(
312
156
            CssProperty::WhiteSpace(StyleWhiteSpaceValue::Exact(StyleWhiteSpace::Nowrap)),
313
        ));
314
156
        props.push(CssPropertyWithConditions::simple(
315
156
            CssProperty::const_overflow_x(LayoutOverflow::Hidden),
316
        ));
317
        // Vertically center the text: pad from top by (height - font_size) / 2
318
156
        let v_pad = ((self.height - self.font_size) / 2.0).max(0.0);
319
156
        if v_pad > 0.0 {
320
144
            props.push(CssPropertyWithConditions::simple(
321
144
                CssProperty::const_padding_top(LayoutPaddingTop::const_px(v_pad as isize)),
322
144
            ));
323
144
        }
324
156
        CssPropertyWithConditionsVec::from_vec(props)
325
156
    }
326

            
327
    /// Title-only DOM (for `NoTitleAutoInject`).
328
    ///
329
    /// The OS draws the native window-control buttons; this just renders
330
    /// a centred title with drag support.
331
    #[inline]
332
38
    #[must_use] pub fn dom(self) -> Dom {
333
38
        self.dom_inner(false, &TitlebarButtons::default(), TitlebarButtonSide::Right)
334
38
    }
335

            
336
    /// Full-CSD DOM with close / minimize / maximize buttons.
337
    ///
338
    /// Each button is a div with a `MouseDown` callback that calls
339
    /// `modify_window_state()` - no special hooks needed.
340
80
    #[must_use] pub fn dom_with_buttons(
341
80
        self,
342
80
        buttons: &TitlebarButtons,
343
80
        button_side: TitlebarButtonSide,
344
80
    ) -> Dom {
345
80
        self.dom_inner(true, buttons, button_side)
346
80
    }
347

            
348
    /// Inner builder for both modes.
349
    #[allow(clippy::trivially_copy_pass_by_ref)] // <=8B Copy param kept by-ref intentionally (hot pixel/coord path or to avoid churning call sites for a perf-neutral change)
350
118
    fn dom_inner(
351
118
        self,
352
118
        show_buttons: bool,
353
118
        buttons: &TitlebarButtons,
354
118
        button_side: TitlebarButtonSide,
355
118
    ) -> Dom {
356
        use azul_core::{
357
            callbacks::{CoreCallback, CoreCallbackData},
358
            dom::{EventFilter, HoverEventFilter},
359
        };
360

            
361
        #[derive(Debug, Clone, Copy)]
362
        struct DragMarker;
363

            
364
        // Build styles BEFORE moving self.title
365
118
        let title_style = self.build_title_style(show_buttons);
366
118
        let container_style = self.build_container_style(show_buttons);
367

            
368
        // ── Title node with drag callbacks ──
369
118
        let title_classes = IdOrClassVec::from_vec(vec![Class("csd-title".into())]);
370

            
371
118
        let title_node = Dom::create_div()
372
118
            .with_ids_and_classes(title_classes)
373
118
            .with_css_props(title_style)
374
118
            .with_child(Dom::create_p_with_text(self.title)) // moves self.title
375
118
            .with_callbacks(vec![
376
118
                CoreCallbackData {
377
118
                    event: EventFilter::Hover(HoverEventFilter::DragStart),
378
118
                    callback: CoreCallback {
379
118
                        cb: callbacks::titlebar_drag_start as usize,
380
118
                        ctx: azul_core::refany::OptionRefAny::None,
381
118
                    },
382
118
                    refany: RefAny::new(DragMarker),
383
118
                },
384
118
                CoreCallbackData {
385
118
                    event: EventFilter::Hover(HoverEventFilter::Drag),
386
118
                    callback: CoreCallback {
387
118
                        cb: callbacks::titlebar_drag as usize,
388
118
                        ctx: azul_core::refany::OptionRefAny::None,
389
118
                    },
390
118
                    refany: RefAny::new(DragMarker),
391
118
                },
392
118
                CoreCallbackData {
393
118
                    event: EventFilter::Hover(HoverEventFilter::DoubleClick),
394
118
                    callback: CoreCallback {
395
118
                        cb: callbacks::titlebar_double_click as usize,
396
118
                        ctx: azul_core::refany::OptionRefAny::None,
397
118
                    },
398
118
                    refany: RefAny::new(DragMarker),
399
118
                },
400
118
            ].into());
401

            
402
        // ── Button container (CSD mode only) ──
403
118
        let button_container = if show_buttons {
404
80
            Some(build_button_container(buttons))
405
        } else {
406
38
            None
407
        };
408

            
409
        // ── Root ──
410
118
        let container_classes = IdOrClassVec::from_vec(vec![
411
118
            Class("csd-titlebar".into()),
412
118
            Class("__azul-native-titlebar".into()),
413
        ]);
414
118
        let mut root = Dom::create_div()
415
118
            .with_ids_and_classes(container_classes)
416
118
            .with_css_props(container_style);
417

            
418
        // Button side determines child order:
419
        //   Left  (macOS):   [buttons] [title]
420
        //   Right (Win/Lin): [title] [buttons]
421
118
        match button_side {
422
            TitlebarButtonSide::Left => {
423
44
                if let Some(btn) = button_container { root = root.with_child(btn); }
424
44
                root = root.with_child(title_node);
425
            }
426
            TitlebarButtonSide::Right => {
427
74
                root = root.with_child(title_node);
428
74
                if let Some(btn) = button_container { root = root.with_child(btn); }
429
            }
430
        }
431

            
432
118
        root
433
118
    }
434
}
435

            
436
/// Build the `.csd-buttons` container with close/min/max button DOM nodes.
437
#[allow(clippy::trivially_copy_pass_by_ref)] // <=8B Copy param kept by-ref intentionally (hot pixel/coord path or to avoid churning call sites for a perf-neutral change)
438
89
fn build_button_container(buttons: &TitlebarButtons) -> Dom {
439
    use azul_core::{
440
        callbacks::{CoreCallback, CoreCallbackData},
441
        dom::{EventFilter, HoverEventFilter},
442
    };
443

            
444
89
    let mut children = Vec::new();
445

            
446
89
    if buttons.has_minimize {
447
51
        let classes = IdOrClassVec::from_vec(vec![
448
51
            Id("csd-button-minimize".into()),
449
51
            Class("csd-button".into()),
450
51
            Class("csd-minimize".into()),
451
51
        ]);
452
51
        children.push(Dom::create_div()
453
51
            .with_ids_and_classes(classes)
454
51
            .with_child(Dom::create_icon("minimize"))
455
51
            .with_callbacks(vec![CoreCallbackData {
456
51
                event: EventFilter::Hover(HoverEventFilter::MouseDown),
457
51
                callback: CoreCallback {
458
51
                    cb: callbacks::csd_minimize as usize,
459
51
                    ctx: azul_core::refany::OptionRefAny::None,
460
51
                },
461
51
                refany: RefAny::new(()),
462
51
            }].into()));
463
51
    }
464

            
465
89
    if buttons.has_maximize {
466
53
        let classes = IdOrClassVec::from_vec(vec![
467
53
            Id("csd-button-maximize".into()),
468
53
            Class("csd-button".into()),
469
53
            Class("csd-maximize".into()),
470
53
        ]);
471
53
        children.push(Dom::create_div()
472
53
            .with_ids_and_classes(classes)
473
53
            .with_child(Dom::create_icon("maximize"))
474
53
            .with_callbacks(vec![CoreCallbackData {
475
53
                event: EventFilter::Hover(HoverEventFilter::MouseDown),
476
53
                callback: CoreCallback {
477
53
                    cb: callbacks::csd_maximize as usize,
478
53
                    ctx: azul_core::refany::OptionRefAny::None,
479
53
                },
480
53
                refany: RefAny::new(()),
481
53
            }].into()));
482
53
    }
483

            
484
89
    if buttons.has_close {
485
53
        let classes = IdOrClassVec::from_vec(vec![
486
53
            Id("csd-button-close".into()),
487
53
            Class("csd-button".into()),
488
53
            Class("csd-close".into()),
489
53
        ]);
490
53
        children.push(Dom::create_div()
491
53
            .with_ids_and_classes(classes)
492
53
            .with_child(Dom::create_icon("close"))
493
53
            .with_callbacks(vec![CoreCallbackData {
494
53
                event: EventFilter::Hover(HoverEventFilter::MouseDown),
495
53
                callback: CoreCallback {
496
53
                    cb: callbacks::csd_close as usize,
497
53
                    ctx: azul_core::refany::OptionRefAny::None,
498
53
                },
499
53
                refany: RefAny::new(()),
500
53
            }].into()));
501
53
    }
502

            
503
89
    let classes = IdOrClassVec::from_vec(vec![Class("csd-buttons".into())]);
504
89
    Dom::create_div()
505
89
        .with_ids_and_classes(classes)
506
89
        .with_children(DomVec::from_vec(children))
507
89
}
508

            
509
impl From<Titlebar> for Dom {
510
10
    fn from(t: Titlebar) -> Self { t.dom() }
511
}
512

            
513
impl Default for Titlebar {
514
19
    fn default() -> Self {
515
19
        Self::new(AzString::from_const_str(""))
516
19
    }
517
}
518

            
519
// ── Titlebar callbacks ───────────────────────────────────────────────────
520

            
521
/// All titlebar callbacks: drag, double-click, close, minimize, maximize.
522
///
523
/// Every callback is a plain `extern "C"` function that uses
524
/// `CallbackInfo::modify_window_state()`.  No special hooks needed.
525
pub(crate) mod callbacks {
526
    use azul_core::callbacks::Update;
527
    use azul_core::refany::RefAny;
528
    use crate::callbacks::CallbackInfo;
529

            
530
    /// `DragStart` - on Wayland, initiate compositor-managed move immediately.
531
    /// On other platforms, just acknowledge (movement happens in `titlebar_drag`).
532
6
    pub(super) extern "C" fn titlebar_drag_start(
533
6
        _data: RefAny, mut info: CallbackInfo,
534
6
    ) -> Update {
535
        // On Wayland, window position is Uninitialized (compositor hides it).
536
        // We must use xdg_toplevel_move via begin_interactive_move().
537
        // MWA-B9 (D2): macOS ALSO takes the native path — the backend maps
538
        // begin_interactive_move to performWindowDragWithEvent:, which is
539
        // OS-smooth / snap-aware / multi-monitor-correct; the manual
540
        // per-event position loop below remains for X11/Windows and as the
541
        // programmatic fallback.
542
6
        let ws = info.get_current_window_state().clone();
543
6
        let native_move = matches!(ws.position, azul_core::window::WindowPosition::Uninitialized)
544
5
            || cfg!(target_os = "macos");
545
6
        if native_move {
546
1
            info.begin_interactive_move();
547
1
        } else {
548
            // MWA-C-csd: reset the fractional-residual accumulator for the
549
            // manual move loop (see titlebar_drag).
550
5
            RESIDUAL_X_BITS.store(0f32.to_bits(), core::sync::atomic::Ordering::Relaxed);
551
5
            RESIDUAL_Y_BITS.store(0f32.to_bits(), core::sync::atomic::Ordering::Relaxed);
552
            // MWA-C-csd: dragging a maximized window restores it first —
553
            // the native paths get this from the OS drag loop, but the
554
            // manual loop moved the still-maximized frame around.
555
5
            if ws.flags.frame == azul_core::window::WindowFrame::Maximized {
556
1
                let mut s = ws;
557
1
                s.flags.frame = azul_core::window::WindowFrame::Normal;
558
1
                info.modify_window_state(s);
559
4
            }
560
        }
561
6
        Update::DoNothing
562
6
    }
563

            
564
    /// MWA-C-csd: fractional-residual carry for the manual drag loop -
565
    /// rounding alone still loses up to half a pixel per event in a
566
    /// consistent direction, so very slow trackpad drags crawled. Only one
567
    /// interactive drag exists at a time and callbacks run on the UI
568
    /// thread; atomics keep this no_std-friendly (f32 stored as bits).
569
    static RESIDUAL_X_BITS: core::sync::atomic::AtomicU32 =
570
        core::sync::atomic::AtomicU32::new(0);
571
    static RESIDUAL_Y_BITS: core::sync::atomic::AtomicU32 =
572
        core::sync::atomic::AtomicU32::new(0);
573

            
574
    /// Drag - apply incremental screen-space delta to the CURRENT window position.
575
    ///
576
    /// Uses `get_drag_delta_screen_incremental()` (frame-to-frame delta) instead of
577
    /// `get_drag_delta_screen()` (total delta since drag start). Combined with
578
    /// the current window position from the OS, this approach is robust against
579
    /// external position changes during the drag (DPI change, OS clamping,
580
    /// compositor resize).
581
    ///
582
    /// On Wayland: this is a no-op because the compositor manages the move
583
    /// (initiated by `begin_interactive_move()` in `titlebar_drag_start`).
584
    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
585
6
    pub(super) extern "C" fn titlebar_drag(
586
6
        _data: RefAny, mut info: CallbackInfo,
587
6
    ) -> Update {
588
        use azul_core::window::WindowPosition;
589
        use azul_core::geom::PhysicalPositionI32;
590

            
591
6
        let delta = info.get_drag_delta_screen_incremental();
592
6
        let current_pos = info.get_current_window_state().position;
593

            
594
6
        if let (azul_core::geom::OptionDragDelta::Some(d), WindowPosition::Initialized(pos)) = (delta, current_pos) {
595
            use core::sync::atomic::Ordering;
596
            // MWA-C-csd: full fractional-residual carry (upgrades MWA-B9's
597
            // round-only fix). Each event applies the integer part of
598
            // delta + residual and carries the remainder, so arbitrarily
599
            // slow drags advance losslessly.
600
            let total_x = d.dx + f32::from_bits(RESIDUAL_X_BITS.load(Ordering::Relaxed));
601
            let total_y = d.dy + f32::from_bits(RESIDUAL_Y_BITS.load(Ordering::Relaxed));
602
            let apply_x = total_x.round();
603
            let apply_y = total_y.round();
604
            RESIDUAL_X_BITS.store((total_x - apply_x).to_bits(), Ordering::Relaxed);
605
            RESIDUAL_Y_BITS.store((total_y - apply_y).to_bits(), Ordering::Relaxed);
606
            let new_pos = WindowPosition::Initialized(PhysicalPositionI32::new(
607
                pos.x + apply_x as i32,
608
                pos.y + apply_y as i32,
609
            ));
610
            let mut ws = info.get_current_window_state().clone();
611
            ws.position = new_pos;
612
            info.modify_window_state(ws);
613
6
        }
614
        // On Wayland: current_pos is Uninitialized, so the if-let doesn't match → no-op.
615
6
        Update::DoNothing
616
6
    }
617

            
618
    /// `DoubleClick` - toggle Maximized ↔ Normal.
619
10
    pub(super) extern "C" fn titlebar_double_click(
620
10
        _data: RefAny, mut info: CallbackInfo,
621
10
    ) -> Update {
622
        use azul_core::window::WindowFrame;
623
10
        let mut s = info.get_current_window_state().clone();
624
10
        s.flags.frame = if s.flags.frame == WindowFrame::Maximized {
625
7
            WindowFrame::Normal } else { WindowFrame::Maximized };
626
10
        info.modify_window_state(s);
627
10
        Update::DoNothing
628
10
    }
629

            
630
    /// Close button - `close_requested = true`.
631
5
    pub(super) extern "C" fn csd_close(
632
5
        _data: RefAny, mut info: CallbackInfo,
633
5
    ) -> Update {
634
5
        let mut s = info.get_current_window_state().clone();
635
5
        s.flags.close_requested = true;
636
5
        info.modify_window_state(s);
637
5
        Update::DoNothing
638
5
    }
639

            
640
    /// Minimize button - `frame = Minimized`.
641
5
    pub(super) extern "C" fn csd_minimize(
642
5
        _data: RefAny, mut info: CallbackInfo,
643
5
    ) -> Update {
644
        use azul_core::window::WindowFrame;
645
5
        let mut s = info.get_current_window_state().clone();
646
5
        s.flags.frame = WindowFrame::Minimized;
647
5
        info.modify_window_state(s);
648
5
        Update::DoNothing
649
5
    }
650

            
651
    /// Maximize button - toggle Maximized ↔ Normal.
652
4
    pub(super) extern "C" fn csd_maximize(
653
4
        _data: RefAny, mut info: CallbackInfo,
654
4
    ) -> Update {
655
        use azul_core::window::WindowFrame;
656
4
        let mut s = info.get_current_window_state().clone();
657
4
        s.flags.frame = if s.flags.frame == WindowFrame::Maximized {
658
3
            WindowFrame::Normal } else { WindowFrame::Maximized };
659
4
        info.modify_window_state(s);
660
4
        Update::DoNothing
661
4
    }
662
}
663

            
664
#[cfg(test)]
665
#[allow(
666
    clippy::float_cmp,
667
    clippy::cast_possible_truncation,
668
    clippy::cast_precision_loss,
669
    clippy::too_many_lines,
670
    clippy::unreadable_literal
671
)]
672
mod autotest_generated {
673
    use std::{
674
        collections::BTreeMap,
675
        sync::{Arc, Mutex},
676
    };
677

            
678
    use azul_core::{
679
        callbacks::Update,
680
        dom::{DomId, DomNodeId, EventFilter, HoverEventFilter, NodeId, NodeType},
681
        geom::{OptionLogicalPosition, PhysicalPositionI32},
682
        gl::OptionGlContextPtr,
683
        hit_test::ScrollPosition,
684
        refany::OptionRefAny,
685
        resources::RendererResources,
686
        styled_dom::NodeHierarchyItemId,
687
        window::{MonitorVec, RawWindowHandle, WindowFrame, WindowPosition},
688
    };
689
    use azul_css::{
690
        props::basic::{length::SizeMetric, pixel::PixelValue},
691
        system::SafeAreaInsets,
692
    };
693
    use rust_fontconfig::FcFontCache;
694

            
695
    use super::*;
696
    #[cfg(feature = "icu")]
697
    use crate::icu::IcuLocalizerHandle;
698
    use crate::{
699
        callbacks::{CallbackChange, CallbackInfo, CallbackInfoRefData, ExternalSystemCallbacks},
700
        window::LayoutWindow,
701
        window_state::FullWindowState,
702
    };
703

            
704
    // ==================================================================
705
    // Helpers
706
    // ==================================================================
707

            
708
    /// Titles a caller can realistically hand to a titlebar. The widget never
709
    /// parses, trims or normalises its title, so every one of these has to reach
710
    /// the DOM byte-for-byte — `AzString` is length-based, so an embedded NUL
711
    /// must not truncate, and a ZWJ emoji cluster must not be split.
712
    const ADVERSARIAL_TITLES: [&str; 10] = [
713
        "",
714
        " ",
715
        "My Window",
716
        "a\0b",
717
        "\0",
718
        "e\u{0301}\u{0301}\u{0301}",
719
        "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}",
720
        "\u{202E}gnirts desrever\u{202C}",
721
        "\u{FFFD}\u{FEFF}\t\n",
722
        "\u{200B}",
723
    ];
724

            
725
    /// Every `f32` the numeric surface (`height` / `font_size`) has to survive
726
    /// *without* tipping the fixed-point encoding over — see
727
    /// `heights_outside_the_encodable_range_are_not_saturated` for the ones that do.
728
    const TAME_FLOATS: [f32; 14] = [
729
        0.0,
730
        -0.0,
731
        1.0,
732
        -1.0,
733
        0.5,
734
        -0.5,
735
        30.0,
736
        1000.0,
737
        -1000.0,
738
        0.999,
739
        f32::EPSILON,
740
        f32::MIN_POSITIVE,
741
        -f32::MIN_POSITIVE,
742
        f32::NAN,
743
    ];
744

            
745
    /// The magnitudes that overflow `PixelValue`'s `value * 1000` encoding on
746
    /// every pointer width: `as isize` saturates them to `isize::MIN`/`MAX`.
747
    const UNENCODABLE_FLOATS: [f32; 4] =
748
        [f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN];
749

            
750
    /// Every `TitlebarButtonSide`.
751
    const BOTH_SIDES: [TitlebarButtonSide; 2] =
752
        [TitlebarButtonSide::Left, TitlebarButtonSide::Right];
753

            
754
    /// Every `WindowFrame` a titlebar callback can be invoked against.
755
    const ALL_FRAMES: [WindowFrame; 4] = [
756
        WindowFrame::Normal,
757
        WindowFrame::Minimized,
758
        WindowFrame::Maximized,
759
        WindowFrame::Fullscreen,
760
    ];
761

            
762
    fn tb(title: &str) -> Titlebar {
763
        Titlebar::new(AzString::from(title))
764
    }
765

            
766
    /// The declared properties of a style vec, in declaration order.
767
    fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
768
        v.as_ref().iter().map(|p| p.property.clone()).collect()
769
    }
770

            
771
    /// Every declaration must be unconditional: a titlebar built with a
772
    /// `@media`/`:hover` guard would silently not apply.
773
    fn all_unconditional(v: &CssPropertyWithConditionsVec) -> bool {
774
        v.as_ref().iter().all(|p| p.apply_if.as_ref().is_empty())
775
    }
776

            
777
    /// The `f32` of a `PixelValue`, asserting it is an absolute `px` length. An
778
    /// `em`/`%` height would resolve against the parent font/box instead of the
779
    /// fixed chrome geometry the titlebar is supposed to reserve.
780
    fn px(pv: &PixelValue) -> f32 {
781
        assert_eq!(
782
            pv.metric,
783
            SizeMetric::Px,
784
            "titlebar geometry must be absolute px, got {:?}",
785
            pv.metric
786
        );
787
        pv.number.get()
788
    }
789

            
790
    fn height_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
791
        v.as_ref().iter().find_map(|p| match &p.property {
792
            CssProperty::Height(h) => match h.get_property() {
793
                Some(LayoutHeight::Px(pv)) => Some(px(pv)),
794
                _ => None,
795
            },
796
            _ => None,
797
        })
798
    }
799

            
800
    fn padding_left_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
801
        v.as_ref().iter().find_map(|p| match &p.property {
802
            CssProperty::PaddingLeft(x) => x.get_property().map(|x| px(&x.inner)),
803
            _ => None,
804
        })
805
    }
806

            
807
    fn padding_right_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
808
        v.as_ref().iter().find_map(|p| match &p.property {
809
            CssProperty::PaddingRight(x) => x.get_property().map(|x| px(&x.inner)),
810
            _ => None,
811
        })
812
    }
813

            
814
    fn padding_top_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
815
        v.as_ref().iter().find_map(|p| match &p.property {
816
            CssProperty::PaddingTop(x) => x.get_property().map(|x| px(&x.inner)),
817
            _ => None,
818
        })
819
    }
820

            
821
    fn font_size_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
822
        v.as_ref().iter().find_map(|p| match &p.property {
823
            CssProperty::FontSize(f) => f.get_property().map(|f| px(&f.inner)),
824
            _ => None,
825
        })
826
    }
827

            
828
    fn text_color(v: &CssPropertyWithConditionsVec) -> Option<ColorU> {
829
        v.as_ref().iter().find_map(|p| match &p.property {
830
            CssProperty::TextColor(c) => c.get_property().map(|c| c.inner),
831
            _ => None,
832
        })
833
    }
834

            
835
    /// The exact container declarations the widget documents, for a given mode.
836
    fn expected_container(t: &Titlebar, show_buttons: bool) -> Vec<CssProperty> {
837
        let mut v = Vec::new();
838
        if show_buttons {
839
            v.push(CssProperty::const_display(LayoutDisplay::Flex));
840
            v.push(CssProperty::const_flex_direction(LayoutFlexDirection::Row));
841
            v.push(CssProperty::const_align_items(LayoutAlignItems::Center));
842
        } else {
843
            v.push(CssProperty::const_display(LayoutDisplay::Block));
844
        }
845
        v.push(CssProperty::const_height(LayoutHeight::const_px(t.height as isize)));
846
        v.push(CssProperty::const_cursor(StyleCursor::Grab));
847
        v.push(CssProperty::user_select(StyleUserSelect::None));
848
        if t.padding_left > 0.0 {
849
            v.push(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
850
                t.padding_left as isize,
851
            )));
852
        }
853
        if t.padding_right > 0.0 {
854
            v.push(CssProperty::const_padding_right(LayoutPaddingRight::const_px(
855
                t.padding_right as isize,
856
            )));
857
        }
858
        v
859
    }
860

            
861
    /// The exact title declarations the widget documents, for a given mode.
862
    fn expected_title(t: &Titlebar, show_buttons: bool) -> Vec<CssProperty> {
863
        let font_family = StyleFontFamilyVec::from_vec(vec![StyleFontFamily::SystemType(
864
            SystemFontType::TitleBold,
865
        )]);
866
        let mut v = vec![
867
            CssProperty::const_font_size(StyleFontSize::const_px(t.font_size as isize)),
868
            CssProperty::const_font_family(font_family),
869
            CssProperty::const_text_color(StyleTextColor { inner: t.title_color }),
870
        ];
871
        if show_buttons {
872
            v.push(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1)));
873
            v.push(CssProperty::const_min_width(LayoutMinWidth::const_px(0)));
874
        }
875
        v.push(CssProperty::const_text_align(StyleTextAlign::Center));
876
        v.push(CssProperty::WhiteSpace(StyleWhiteSpaceValue::Exact(
877
            StyleWhiteSpace::Nowrap,
878
        )));
879
        v.push(CssProperty::const_overflow_x(LayoutOverflow::Hidden));
880
        let v_pad = ((t.height - t.font_size) / 2.0).max(0.0);
881
        if v_pad > 0.0 {
882
            v.push(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
883
                v_pad as isize,
884
            )));
885
        }
886
        v
887
    }
888

            
889
    /// True if `node` carries the CSS class `name`.
890
    fn has_class(node: &Dom, name: &str) -> bool {
891
        node.root
892
            .get_ids_and_classes()
893
            .as_ref()
894
            .iter()
895
            .any(|c| matches!(c, Class(s) if s.as_str() == name))
896
    }
897

            
898
    /// Every id declared on `node`, in order.
899
    fn ids(node: &Dom) -> Vec<String> {
900
        node.root
901
            .get_ids_and_classes()
902
            .as_ref()
903
            .iter()
904
            .filter_map(|c| match c {
905
                Id(s) => Some(s.as_str().to_string()),
906
                Class(_) => None,
907
            })
908
            .collect()
909
    }
910

            
911
    /// Every class declared on `node`, in order.
912
    fn classes(node: &Dom) -> Vec<String> {
913
        node.root
914
            .get_ids_and_classes()
915
            .as_ref()
916
            .iter()
917
            .filter_map(|c| match c {
918
                Class(s) => Some(s.as_str().to_string()),
919
                Id(_) => None,
920
            })
921
            .collect()
922
    }
923

            
924
    /// The text of a text node, looking through the `<p>` block wrapper the
925
    /// label convention mandates (`p > text`).
926
    fn text_of(node: &Dom) -> Option<&str> {
927
        match node.root.get_node_type() {
928
            NodeType::Text(s) => Some(s.as_ref().as_str()),
929
            NodeType::P => match node.children.as_ref() {
930
                [only] => match only.root.get_node_type() {
931
                    NodeType::Text(s) => Some(s.as_ref().as_str()),
932
                    _ => None,
933
                },
934
                _ => None,
935
            },
936
            _ => None,
937
        }
938
    }
939

            
940
    /// The icon name of a `NodeType::Icon` node.
941
    fn icon_of(node: &Dom) -> Option<&str> {
942
        match node.root.get_node_type() {
943
            NodeType::Icon(s) => Some(s.as_ref().as_str()),
944
            _ => None,
945
        }
946
    }
947

            
948
    /// A node's *inline* style properties, in declaration order.
949
    fn inline_props(node: &Dom) -> Vec<CssProperty> {
950
        node.root.style.iter_inline_properties().map(|(p, _)| p.clone()).collect()
951
    }
952

            
953
    /// `(event, callback fn address)` for every callback on `node`, in order.
954
    fn callbacks_of(node: &Dom) -> Vec<(EventFilter, usize)> {
955
        node.root
956
            .get_callbacks()
957
            .as_ref()
958
            .iter()
959
            .map(|c| (c.event, c.callback.cb))
960
            .collect()
961
    }
962

            
963
    /// The recursive descendant count. `Dom::estimated_total_children` is a
964
    /// *cached* value that, if too small, makes `convert_dom_into_compact_dom`
965
    /// under-allocate its arenas and panic on out-of-bounds writes — so it has to
966
    /// match this exactly for every button combination.
967
    fn count_descendants(dom: &Dom) -> usize {
968
        dom.children.as_ref().iter().map(|c| 1 + count_descendants(c)).sum()
969
    }
970

            
971
    /// A pre-order, structural fingerprint of a DOM: node type, ids/classes,
972
    /// `(event, fn address)` per callback and inline declarations. Used instead of
973
    /// `Dom: PartialEq` because the drag callbacks carry freshly allocated
974
    /// `RefAny`s, which compare by pointer and so are never equal across builds.
975
    fn fingerprint(dom: &Dom) -> Vec<String> {
976
        fn walk(d: &Dom, depth: usize, out: &mut Vec<String>) {
977
            out.push(format!(
978
                "{depth}|{:?}|{:?}|{:?}|{:?}|{:?}",
979
                d.root.get_node_type(),
980
                ids(d),
981
                classes(d),
982
                callbacks_of(d),
983
                inline_props(d),
984
            ));
985
            for c in d.children.as_ref() {
986
                walk(c, depth + 1, out);
987
            }
988
        }
989
        let mut out = Vec::new();
990
        walk(dom, 0, &mut out);
991
        out
992
    }
993

            
994
    /// The title node of a rendered titlebar (the `.csd-title` div).
995
    fn title_node(dom: &Dom) -> &Dom {
996
        dom.children
997
            .as_ref()
998
            .iter()
999
            .find(|c| has_class(c, "csd-title"))
            .expect("every titlebar must render a .csd-title node")
    }
    /// The `.csd-buttons` node of a rendered titlebar, if there is one.
    fn buttons_node(dom: &Dom) -> Option<&Dom> {
        dom.children.as_ref().iter().find(|c| has_class(c, "csd-buttons"))
    }
    fn all_button_combinations() -> Vec<TitlebarButtons> {
        let mut out = Vec::new();
        for &close in &[false, true] {
            for &min in &[false, true] {
                for &max in &[false, true] {
                    for &full in &[false, true] {
                        out.push(TitlebarButtons {
                            has_close: close,
                            has_minimize: min,
                            has_maximize: max,
                            has_fullscreen: full,
                        });
                    }
                }
            }
        }
        out
    }
    /// A `SystemStyle` whose titlebar metrics are all "not detected" — the state
    /// `SystemStyle::default()` ships and the one the fallbacks exist for.
    fn blank_system_style() -> SystemStyle {
        SystemStyle::default()
    }
    /// Runs `f` against a `CallbackInfo` backed by `state`, returning `f`'s result
    /// plus every recorded `CallbackChange`. No layout result is inserted: none of
    /// the titlebar callbacks walk the DOM.
    fn with_callback_info<R>(
        state: FullWindowState,
        f: impl FnOnce(CallbackInfo) -> R,
    ) -> (R, Vec<CallbackChange>) {
        let layout_window =
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
        let renderer_resources = RendererResources::default();
        let previous_window_state: Option<FullWindowState> = None;
        let current_window_state = state;
        let gl_context = OptionGlContextPtr::None;
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
            BTreeMap::new();
        let window_handle = RawWindowHandle::Unsupported;
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
        let ref_data = CallbackInfoRefData {
            layout_window: &layout_window,
            renderer_resources: &renderer_resources,
            previous_window_state: &previous_window_state,
            current_window_state: &current_window_state,
            gl_context: &gl_context,
            current_scroll_manager: &scroll_states,
            current_window_handle: &window_handle,
            system_callbacks: &system_callbacks,
            system_style: Arc::new(SystemStyle::default()),
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
            #[cfg(feature = "icu")]
            icu_localizer: IcuLocalizerHandle::default(),
            ctx: OptionRefAny::None,
        };
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
        let info = CallbackInfo::new(
            &ref_data,
            &changes,
            DomNodeId {
                dom: DomId::ROOT_ID,
                node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(0))),
            },
            OptionLogicalPosition::None,
            OptionLogicalPosition::None,
        );
        let out = f(info);
        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
        (out, recorded)
    }
    /// The window states pushed through `modify_window_state`, in order.
    fn state_writes(changes: &[CallbackChange]) -> Vec<FullWindowState> {
        changes
            .iter()
            .filter_map(|c| match c {
                CallbackChange::ModifyWindowState { state } => Some(state.clone()),
                _ => None,
            })
            .collect()
    }
    fn interactive_moves(changes: &[CallbackChange]) -> usize {
        changes
            .iter()
            .filter(|c| matches!(c, CallbackChange::BeginInteractiveMove))
            .count()
    }
    fn state_with(frame: WindowFrame, position: WindowPosition) -> FullWindowState {
        let mut s = FullWindowState::default();
        s.flags.frame = frame;
        s.position = position;
        s
    }
    // ==================================================================
    // Titlebar::new / Titlebar::create / Default
    // ==================================================================
    #[test]
    fn new_uses_the_compile_time_platform_defaults() {
        let t = tb("hello");
        assert_eq!(t.title.as_str(), "hello");
        assert_eq!(t.height, DEFAULT_TITLEBAR_HEIGHT);
        assert_eq!(t.font_size, DEFAULT_TITLE_FONT_SIZE);
        assert_eq!(t.title_color, DEFAULT_TITLE_COLOR_LIGHT);
        assert_eq!(t.padding_left, DEFAULT_BUTTON_AREA_WIDTH / 2.0);
        assert_eq!(t.padding_right, DEFAULT_BUTTON_AREA_WIDTH / 2.0);
    }
    #[test]
    fn new_pads_both_sides_equally_so_centering_lands_at_the_window_midpoint() {
        // The doc comment is explicit: the button-side half clears the OS buttons,
        // the opposite half balances `text-align: center`. Asymmetric padding would
        // push the title off the window midpoint.
        let t = tb("x");
        assert_eq!(
            t.padding_left, t.padding_right,
            "title-only padding must stay symmetric",
        );
        assert!(t.padding_left >= 0.0, "negative reserved space is meaningless");
        assert!(t.height > 0.0 && t.height.is_finite());
        assert!(t.font_size > 0.0 && t.font_size.is_finite());
        assert!(
            t.font_size < t.height,
            "the default font must fit inside the default titlebar height",
        );
    }
    #[test]
    fn new_stores_pathological_titles_byte_for_byte() {
        for title in ADVERSARIAL_TITLES {
            let t = tb(title);
            assert_eq!(t.title.as_str(), title, "the title was mangled or normalised");
            assert_eq!(
                t.title.as_str().len(),
                title.len(),
                "the title was truncated (an embedded NUL must not terminate it)",
            );
        }
    }
    #[test]
    fn new_accepts_a_title_far_longer_than_any_real_window_caption() {
        let huge = "a".repeat(1_000_000);
        let t = Titlebar::new(AzString::from(huge.clone()));
        assert_eq!(t.title.as_str().len(), 1_000_000);
        // ... and it survives the trip into the DOM without being re-encoded.
        let dom = t.dom();
        assert_eq!(text_of(&title_node(&dom).children.as_ref()[0]), Some(huge.as_str()));
    }
    #[test]
    fn create_is_indistinguishable_from_new() {
        for title in ADVERSARIAL_TITLES {
            assert_eq!(
                Titlebar::create(AzString::from(title)),
                Titlebar::new(AzString::from(title)),
                "the FFI alias drifted away from Titlebar::new",
            );
        }
    }
    #[test]
    fn default_is_new_with_an_empty_title() {
        let d = Titlebar::default();
        assert_eq!(d, tb(""));
        assert_eq!(d.title.as_str(), "");
    }
    // ==================================================================
    // Titlebar::with_height / Titlebar::set_height
    // ==================================================================
    #[test]
    fn with_height_stores_every_float_bit_exactly_and_touches_nothing_else() {
        let base = tb("t");
        for h in TAME_FLOATS.into_iter().chain(UNENCODABLE_FLOATS) {
            let t = Titlebar::with_height(AzString::from("t"), h);
            // to_bits, not `==`: NaN != NaN, and -0.0 == 0.0 would hide a sign flip.
            assert_eq!(
                t.height.to_bits(),
                h.to_bits(),
                "with_height({h}) did not store the value verbatim",
            );
            assert_eq!(t.title.as_str(), "t");
            assert_eq!(t.font_size, base.font_size, "with_height({h}) moved the font size");
            assert_eq!(t.padding_left, base.padding_left, "with_height({h}) moved the padding");
            assert_eq!(t.padding_right, base.padding_right, "with_height({h}) moved the padding");
            assert_eq!(t.title_color, base.title_color, "with_height({h}) moved the colour");
        }
    }
    #[test]
    fn set_height_is_a_bit_exact_last_write_wins_store() {
        let mut t = tb("t");
        let base = tb("t");
        for h in TAME_FLOATS.into_iter().chain(UNENCODABLE_FLOATS) {
            t.set_height(h);
            assert_eq!(t.height.to_bits(), h.to_bits(), "set_height({h}) was not verbatim");
            assert_eq!(t.font_size, base.font_size);
            assert_eq!(t.padding_left, base.padding_left);
            assert_eq!(t.padding_right, base.padding_right);
            assert_eq!(t.title.as_str(), "t", "set_height({h}) disturbed the title");
        }
        // The last write is the one that survives; nothing accumulates.
        t.set_height(7.5);
        t.set_height(9.25);
        assert_eq!(t.height, 9.25);
    }
    #[test]
    fn set_height_zero_and_negative_are_stored_not_clamped() {
        // The setter is documented as a plain store — it is `build_container_style`
        // that has to survive the result, not the setter.
        let mut t = tb("t");
        t.set_height(0.0);
        assert_eq!(t.height.to_bits(), 0_f32.to_bits(), "0.0 must stay +0.0");
        t.set_height(-0.0);
        assert_eq!(t.height.to_bits(), (-0.0_f32).to_bits(), "-0.0 must not be normalised");
        t.set_height(-42.0);
        assert_eq!(t.height, -42.0, "a negative height must not be clamped by the setter");
    }
    // ==================================================================
    // Titlebar::set_title
    // ==================================================================
    #[test]
    fn set_title_replaces_the_title_and_leaves_the_geometry_alone() {
        let mut t = Titlebar::with_height(AzString::from("first"), 44.0);
        for title in ADVERSARIAL_TITLES {
            t.set_title(AzString::from(title));
            assert_eq!(t.title.as_str(), title);
            assert_eq!(t.title.as_str().len(), title.len());
            assert_eq!(t.height, 44.0, "set_title moved the height");
            assert_eq!(t.padding_left, tb("").padding_left, "set_title moved the padding");
        }
    }
    // ==================================================================
    // Titlebar::swap_with_default
    // ==================================================================
    #[test]
    fn swap_with_default_hands_back_the_old_value_and_leaves_a_default() {
        let mut t = Titlebar::with_height(AzString::from("payload"), 99.5);
        t.title_color = ColorU { r: 1, g: 2, b: 3, a: 4 };
        let taken = t.swap_with_default();
        assert_eq!(taken.title.as_str(), "payload", "the title did not travel out");
        assert_eq!(taken.height, 99.5, "the height did not travel out");
        assert_eq!(taken.title_color, ColorU { r: 1, g: 2, b: 3, a: 4 });
        assert_eq!(t, Titlebar::default(), "what was left behind is not a default titlebar");
        assert_eq!(t.title.as_str(), "");
    }
    #[test]
    fn swap_with_default_moves_a_nan_height_out_without_losing_its_bits() {
        // `Titlebar` derives PartialEq, so a NaN height makes the struct
        // self-unequal — the swap still has to move the exact bit pattern.
        let mut t = tb("x");
        t.set_height(f32::NAN);
        let taken = t.swap_with_default();
        assert!(taken.height.is_nan(), "the NaN height did not travel out");
        assert_eq!(t.height, DEFAULT_TITLEBAR_HEIGHT, "the leftover kept the NaN");
        assert_eq!(t, Titlebar::default());
    }
    #[test]
    fn repeated_swap_with_default_never_accumulates_state() {
        let mut t = Titlebar::with_height(AzString::from("x"), 1.0);
        let _first = t.swap_with_default();
        for i in 0..8 {
            let taken = t.swap_with_default();
            assert_eq!(taken, Titlebar::default(), "swap #{i} handed back a non-default");
            assert_eq!(t, Titlebar::default(), "swap #{i} left a non-default behind");
        }
        // The drained titlebar still renders a well-formed DOM.
        let dom = t.dom();
        assert_eq!(dom.children.as_ref().len(), 1);
        assert_eq!(text_of(&title_node(&dom).children.as_ref()[0]), Some(""));
    }
    // ==================================================================
    // Titlebar::from_system_style
    // ==================================================================
    #[test]
    fn from_system_style_falls_back_to_the_compile_time_defaults_when_nothing_is_detected() {
        let ss = blank_system_style();
        let t = Titlebar::from_system_style(AzString::from("sys"), &ss);
        assert_eq!(t.title.as_str(), "sys");
        assert_eq!(t.height, DEFAULT_TITLEBAR_HEIGHT, "an undetected height must fall back");
        // `TitlebarMetrics::default()` *does* carry a font size (13.0), so the
        // compile-time default is only reachable when it is explicitly None.
        assert_eq!(t.font_size, 13.0);
        assert_eq!(t.padding_left, DEFAULT_BUTTON_AREA_WIDTH / 2.0);
        assert_eq!(t.padding_right, DEFAULT_BUTTON_AREA_WIDTH / 2.0);
        assert_eq!(t.title_color, DEFAULT_TITLE_COLOR_LIGHT);
    }
    #[test]
    fn from_system_style_with_no_font_size_falls_back_to_the_platform_constant() {
        let mut ss = blank_system_style();
        ss.metrics.titlebar.title_font_size = OptionF32::None;
        let t = Titlebar::from_system_style(AzString::from("x"), &ss);
        assert_eq!(t.font_size, DEFAULT_TITLE_FONT_SIZE);
    }
    #[test]
    fn from_system_style_adds_the_safe_area_and_padding_to_each_side_separately() {
        let mut ss = blank_system_style();
        ss.metrics.titlebar.button_area_width = OptionPixelValue::Some(PixelValue::px(100.0));
        ss.metrics.titlebar.padding_horizontal = OptionPixelValue::Some(PixelValue::px(5.0));
        ss.metrics.titlebar.safe_area = SafeAreaInsets {
            top: OptionPixelValue::None,
            bottom: OptionPixelValue::None,
            left: OptionPixelValue::Some(PixelValue::px(10.0)),
            right: OptionPixelValue::Some(PixelValue::px(20.0)),
        };
        let t = Titlebar::from_system_style(AzString::from("x"), &ss);
        assert_eq!(t.padding_left, 50.0 + 10.0 + 5.0);
        assert_eq!(t.padding_right, 50.0 + 20.0 + 5.0);
        // A notch on one side is exactly the documented case where the padding is
        // deliberately *not* symmetric.
        assert_ne!(t.padding_left, t.padding_right);
    }
    #[test]
    fn from_system_style_reads_the_height_and_font_size_it_was_given() {
        let mut ss = blank_system_style();
        ss.metrics.titlebar.height = OptionPixelValue::Some(PixelValue::px(41.0));
        ss.metrics.titlebar.title_font_size = OptionF32::Some(17.5);
        let t = Titlebar::from_system_style(AzString::from("x"), &ss);
        assert_eq!(t.height, 41.0);
        assert_eq!(t.font_size, 17.5);
    }
    #[test]
    fn from_system_style_converts_absolute_units_but_collapses_relative_ones_to_zero() {
        // `to_pixels_internal(0.0, 0.0, 0.0)` is called with *zero* resolution
        // bases, so anything relative (em/rem/%/vw) silently resolves to 0px —
        // a titlebar height declared in `em` collapses the whole chrome.
        let cases: [(PixelValue, f32); 6] = [
            (PixelValue::px(30.0), 30.0),
            (PixelValue::pt(30.0), 30.0 * (96.0 / 72.0)),
            (PixelValue::em(2.0), 0.0),
            (PixelValue::rem(2.0), 0.0),
            (PixelValue::percent(50.0), 0.0),
            (PixelValue::const_from_metric(SizeMetric::Vh, 50), 0.0),
        ];
        for (pv, expected) in cases {
            let mut ss = blank_system_style();
            ss.metrics.titlebar.height = OptionPixelValue::Some(pv);
            let t = Titlebar::from_system_style(AzString::from("x"), &ss);
            assert!(
                (t.height - expected).abs() < 0.01,
                "{pv:?} resolved to {} px, expected {expected} px",
                t.height,
            );
        }
    }
    #[test]
    fn from_system_style_saturates_a_non_finite_metric_instead_of_propagating_it() {
        // `PixelValue::px(inf)` encodes as `f32_to_isize(inf * 1000) == isize::MAX`,
        // so what comes back out is huge but *finite*: an infinity reaching the
        // layout solver would poison every downstream size computation.
        for bogus in [f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, f32::NAN] {
            let mut ss = blank_system_style();
            ss.metrics.titlebar.height = OptionPixelValue::Some(PixelValue::px(bogus));
            ss.metrics.titlebar.button_area_width = OptionPixelValue::Some(PixelValue::px(bogus));
            let t = Titlebar::from_system_style(AzString::from("x"), &ss);
            assert!(t.height.is_finite(), "{bogus} produced a non-finite height {}", t.height);
            assert!(
                t.padding_left.is_finite() && t.padding_right.is_finite(),
                "{bogus} produced non-finite padding",
            );
        }
        // NaN specifically collapses to zero rather than staying NaN.
        let mut ss = blank_system_style();
        ss.metrics.titlebar.height = OptionPixelValue::Some(PixelValue::px(f32::NAN));
        assert_eq!(Titlebar::from_system_style(AzString::from("x"), &ss).height, 0.0);
    }
    #[test]
    fn from_system_style_prefers_the_detected_text_colour_over_both_theme_fallbacks() {
        let detected = ColorU { r: 9, g: 8, b: 7, a: 6 };
        for theme in [system::Theme::Light, system::Theme::Dark] {
            let mut ss = blank_system_style();
            ss.theme = theme;
            ss.colors.text = OptionColorU::Some(detected);
            assert_eq!(
                Titlebar::from_system_style(AzString::from("x"), &ss).title_color,
                detected,
                "{theme:?}: the detected system text colour must win",
            );
        }
    }
    #[test]
    fn from_system_style_picks_the_theme_appropriate_fallback_colour() {
        let mut light = blank_system_style();
        light.theme = system::Theme::Light;
        light.colors.text = OptionColorU::None;
        assert_eq!(
            Titlebar::from_system_style(AzString::from("x"), &light).title_color,
            DEFAULT_TITLE_COLOR_LIGHT,
        );
        let mut dark = blank_system_style();
        dark.theme = system::Theme::Dark;
        dark.colors.text = OptionColorU::None;
        assert_eq!(
            Titlebar::from_system_style(AzString::from("x"), &dark).title_color,
            DEFAULT_TITLE_COLOR_DARK,
        );
        // The two fallbacks must actually differ, or dark mode renders unreadably.
        assert_ne!(DEFAULT_TITLE_COLOR_LIGHT, DEFAULT_TITLE_COLOR_DARK);
    }
    #[test]
    fn from_system_style_carries_pathological_titles_through_untouched() {
        let ss = blank_system_style();
        for title in ADVERSARIAL_TITLES {
            let t = Titlebar::from_system_style(AzString::from(title), &ss);
            assert_eq!(t.title.as_str(), title);
            let csd = Titlebar::from_system_style_csd(AzString::from(title), &ss);
            assert_eq!(csd.title.as_str(), title);
        }
    }
    // ==================================================================
    // Titlebar::from_system_style_csd
    // ==================================================================
    #[test]
    fn from_system_style_csd_zeroes_the_padding_and_keeps_everything_else() {
        let mut ss = blank_system_style();
        ss.metrics.titlebar.height = OptionPixelValue::Some(PixelValue::px(41.0));
        ss.metrics.titlebar.title_font_size = OptionF32::Some(17.5);
        ss.theme = system::Theme::Dark;
        let title_only = Titlebar::from_system_style(AzString::from("x"), &ss);
        let csd = Titlebar::from_system_style_csd(AzString::from("x"), &ss);
        assert_eq!(csd.height, title_only.height);
        assert_eq!(csd.font_size, title_only.font_size);
        assert_eq!(csd.title_color, title_only.title_color);
        assert_eq!(csd.title_color, DEFAULT_TITLE_COLOR_DARK);
        // The buttons are DOM children in CSD mode, so no space is reserved.
        assert_eq!(csd.padding_left.to_bits(), 0_f32.to_bits());
        assert_eq!(csd.padding_right.to_bits(), 0_f32.to_bits());
    }
    #[test]
    fn from_system_style_csd_ignores_the_button_area_and_safe_area_entirely() {
        let mut ss = blank_system_style();
        ss.metrics.titlebar.button_area_width = OptionPixelValue::Some(PixelValue::px(500.0));
        ss.metrics.titlebar.padding_horizontal = OptionPixelValue::Some(PixelValue::px(77.0));
        ss.metrics.titlebar.safe_area = SafeAreaInsets {
            top: OptionPixelValue::Some(PixelValue::px(1.0)),
            bottom: OptionPixelValue::Some(PixelValue::px(2.0)),
            left: OptionPixelValue::Some(PixelValue::px(3.0)),
            right: OptionPixelValue::Some(PixelValue::px(4.0)),
        };
        let csd = Titlebar::from_system_style_csd(AzString::from("x"), &ss);
        assert_eq!(csd.padding_left, 0.0);
        assert_eq!(csd.padding_right, 0.0);
    }
    // ==================================================================
    // Titlebar::build_container_style
    // ==================================================================
    #[test]
    fn build_container_style_emits_the_documented_declarations_in_both_modes() {
        let t = tb("x");
        for show_buttons in [false, true] {
            let style = t.build_container_style(show_buttons);
            assert_eq!(
                properties(&style),
                expected_container(&t, show_buttons),
                "container declarations drifted (show_buttons = {show_buttons})",
            );
            assert!(all_unconditional(&style), "a container declaration became conditional");
        }
    }
    #[test]
    fn build_container_style_switches_flex_only_for_the_csd_mode() {
        let t = tb("x");
        let block = t.build_container_style(false);
        let flex = t.build_container_style(true);
        assert!(properties(&block).contains(&CssProperty::const_display(LayoutDisplay::Block)));
        assert!(properties(&flex).contains(&CssProperty::const_display(LayoutDisplay::Flex)));
        // Title-only mode must *not* declare flex layout — the doc comment says it
        // deliberately avoids flex-grow complexity.
        assert!(
            !properties(&block)
                .iter()
                .any(|p| matches!(p, CssProperty::FlexDirection(_) | CssProperty::AlignItems(_))),
            "title-only mode leaked flex declarations",
        );
        // Everything else is identical.
        assert_eq!(height_px(&block), height_px(&flex));
        assert_eq!(padding_left_px(&block), padding_left_px(&flex));
        assert_eq!(padding_right_px(&block), padding_right_px(&flex));
    }
    #[test]
    fn build_container_style_always_declares_the_grab_cursor_and_disables_selection() {
        // Without these a drag selects the title text instead of moving the window.
        for show_buttons in [false, true] {
            let style = tb("x").build_container_style(show_buttons);
            let props = properties(&style);
            assert!(props.contains(&CssProperty::const_cursor(StyleCursor::Grab)));
            assert!(props.contains(&CssProperty::user_select(StyleUserSelect::None)));
        }
    }
    #[test]
    fn build_container_style_truncates_the_height_toward_zero() {
        // `height as isize` truncates; a 30.9px titlebar is encoded as 30px.
        for (h, expected) in [
            (30.0_f32, 30.0_f32),
            (30.9, 30.0),
            (-30.9, -30.0),
            (0.0, 0.0),
            (-0.0, 0.0),
            (0.5, 0.0),
            (-0.5, 0.0),
            (0.999, 0.0),
        ] {
            let mut t = tb("x");
            t.set_height(h);
            assert_eq!(
                height_px(&t.build_container_style(false)),
                Some(expected),
                "height {h} encoded wrongly",
            );
        }
    }
    #[test]
    fn build_container_style_encodes_a_nan_height_as_zero_pixels() {
        // `NaN as isize` saturates to 0, so the encoding is defined rather than
        // propagating NaN into the layout solver.
        let mut t = tb("x");
        t.set_height(f32::NAN);
        assert_eq!(height_px(&t.build_container_style(false)), Some(0.0));
        assert_eq!(height_px(&t.build_container_style(true)), Some(0.0));
    }
    #[test]
    fn build_container_style_omits_padding_that_is_not_strictly_positive() {
        for pad in [0.0_f32, -0.0, -1.0, -1e30, f32::NAN, f32::NEG_INFINITY] {
            let mut t = tb("x");
            t.padding_left = pad;
            t.padding_right = pad;
            let style = t.build_container_style(false);
            assert_eq!(
                padding_left_px(&style),
                None,
                "padding-left {pad} must not be declared at all",
            );
            assert_eq!(padding_right_px(&style), None, "padding-right {pad} was declared");
        }
    }
    #[test]
    fn build_container_style_emits_sub_pixel_padding_as_a_zero_px_declaration() {
        // The `> 0.0` gate lets 0.4px through, and `as isize` then truncates it to
        // 0px: the declaration exists but reserves nothing.
        let mut t = tb("x");
        t.padding_left = 0.4;
        t.padding_right = 0.6;
        let style = t.build_container_style(false);
        assert_eq!(padding_left_px(&style), Some(0.0));
        assert_eq!(padding_right_px(&style), Some(0.0));
    }
    #[test]
    fn build_container_style_keeps_the_two_paddings_independent() {
        let mut t = tb("x");
        t.padding_left = 12.0;
        t.padding_right = 0.0;
        let style = t.build_container_style(true);
        assert_eq!(padding_left_px(&style), Some(12.0));
        assert_eq!(padding_right_px(&style), None);
    }
    // ==================================================================
    // Titlebar::build_title_style
    // ==================================================================
    #[test]
    fn build_title_style_emits_the_documented_declarations_in_both_modes() {
        let t = tb("x");
        for show_buttons in [false, true] {
            let style = t.build_title_style(show_buttons);
            assert_eq!(
                properties(&style),
                expected_title(&t, show_buttons),
                "title declarations drifted (show_buttons = {show_buttons})",
            );
            assert!(all_unconditional(&style), "a title declaration became conditional");
        }
    }
    #[test]
    fn build_title_style_only_grows_the_title_in_csd_mode() {
        // In the flex container the title must claim the space left by the buttons,
        // and `min-width: 0` is what lets it actually shrink below its text width.
        let t = tb("x");
        let flex = properties(&t.build_title_style(true));
        assert!(flex.contains(&CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))));
        assert!(flex.contains(&CssProperty::const_min_width(LayoutMinWidth::const_px(0))));
        let block = properties(&t.build_title_style(false));
        assert!(
            !block
                .iter()
                .any(|p| matches!(p, CssProperty::FlexGrow(_) | CssProperty::MinWidth(_))),
            "title-only mode leaked flex-grow / min-width",
        );
    }
    #[test]
    fn build_title_style_always_centres_clips_and_never_wraps() {
        for show_buttons in [false, true] {
            let props = properties(&tb("x").build_title_style(show_buttons));
            assert!(props.contains(&CssProperty::const_text_align(StyleTextAlign::Center)));
            assert!(props.contains(&CssProperty::WhiteSpace(StyleWhiteSpaceValue::Exact(
                StyleWhiteSpace::Nowrap
            ))));
            assert!(props.contains(&CssProperty::const_overflow_x(LayoutOverflow::Hidden)));
        }
    }
    #[test]
    fn build_title_style_forwards_the_resolved_title_colour_verbatim() {
        for c in [
            ColorU { r: 0, g: 0, b: 0, a: 0 },
            ColorU { r: 255, g: 255, b: 255, a: 255 },
            ColorU { r: 1, g: 2, b: 3, a: 4 },
            DEFAULT_TITLE_COLOR_DARK,
        ] {
            let mut t = tb("x");
            t.title_color = c;
            assert_eq!(text_color(&t.build_title_style(false)), Some(c));
            assert_eq!(text_color(&t.build_title_style(true)), Some(c));
        }
    }
    #[test]
    fn build_title_style_centres_vertically_with_half_the_leftover_height() {
        for (h, fs, expected) in [
            (30.0_f32, 13.0_f32, Some(8.0_f32)), // (30-13)/2 = 8.5 -> 8px
            (32.0, 12.0, Some(10.0)),
            (40.0, 20.0, Some(10.0)),
            (14.0, 13.0, Some(0.0)), // 0.5 -> declared, but 0px
        ] {
            let mut t = tb("x");
            t.set_height(h);
            t.font_size = fs;
            assert_eq!(
                padding_top_px(&t.build_title_style(false)),
                expected,
                "h={h} fs={fs} produced the wrong vertical padding",
            );
        }
    }
    #[test]
    fn build_title_style_omits_the_vertical_padding_when_the_text_does_not_fit() {
        // `.max(0.0)` must swallow the negative gap: a negative padding-top would
        // push the title above the titlebar.
        for (h, fs) in [
            (13.0_f32, 13.0_f32),
            (10.0, 20.0),
            (0.0, 13.0),
            (-100.0, 13.0),
            (f32::NEG_INFINITY, 13.0),
            (f32::NAN, 13.0),
            (13.0, f32::NAN),
        ] {
            let mut t = tb("x");
            t.set_height(h);
            t.font_size = fs;
            assert_eq!(
                padding_top_px(&t.build_title_style(false)),
                None,
                "h={h} fs={fs} declared a vertical padding it should have clamped away",
            );
        }
    }
    #[test]
    fn build_title_style_encodes_a_nan_font_size_as_zero_pixels() {
        let mut t = tb("x");
        t.font_size = f32::NAN;
        assert_eq!(font_size_px(&t.build_title_style(false)), Some(0.0));
    }
    #[test]
    fn build_title_style_truncates_the_font_size_toward_zero() {
        for (fs, expected) in [(13.0_f32, 13.0_f32), (13.9, 13.0), (0.5, 0.0), (-13.9, -13.0)] {
            let mut t = tb("x");
            t.font_size = fs;
            assert_eq!(font_size_px(&t.build_title_style(false)), Some(expected));
        }
    }
    // ==================================================================
    // The fixed-point encoding boundary
    // ==================================================================
    #[cfg(panic = "unwind")]
    #[test]
    fn heights_outside_the_encodable_range_are_not_saturated() {
        use std::{
            hint::black_box,
            panic::{catch_unwind, AssertUnwindSafe},
        };
        // LATENT BUG, pinned: `PixelValue::const_px` multiplies by 1000 with a
        // plain `*`, so any height/font-size whose `as isize` truncation exceeds
        // `isize::MAX / 1000` either panics (overflow checks on: a debug build
        // dies) or wraps to a garbage length (checks off) — it never saturates.
        // Asserted against a probe of the *current* profile so the test is
        // profile-independent; adding saturation flips it loudly.
        let profile_traps_overflow = catch_unwind(AssertUnwindSafe(|| {
            let big = black_box(isize::MAX);
            let _ = black_box(big * black_box(1000_isize));
        }))
        .is_err();
        for bogus in UNENCODABLE_FLOATS {
            let mut t = tb("x");
            t.set_height(bogus);
            let panicked =
                catch_unwind(AssertUnwindSafe(|| drop(t.build_container_style(false)))).is_err();
            assert_eq!(
                panicked, profile_traps_overflow,
                "height {bogus}: the fixed-point encoding no longer behaves like a raw multiply",
            );
            let mut f = tb("x");
            f.font_size = bogus;
            let panicked =
                catch_unwind(AssertUnwindSafe(|| drop(f.build_title_style(false)))).is_err();
            assert_eq!(
                panicked, profile_traps_overflow,
                "font size {bogus}: the fixed-point encoding no longer behaves like a raw multiply",
            );
        }
    }
    #[cfg(panic = "unwind")]
    #[test]
    fn an_unencodable_vertical_gap_reaches_the_padding_encoder_unclamped() {
        use std::{
            hint::black_box,
            panic::{catch_unwind, AssertUnwindSafe},
        };
        let profile_traps_overflow = catch_unwind(AssertUnwindSafe(|| {
            let big = black_box(isize::MAX);
            let _ = black_box(big * black_box(1000_isize));
        }))
        .is_err();
        // A *positive* unencodable height also blows up through `padding-top`,
        // because `(h - fs) / 2` is still unencodable. The negative ones are
        // clamped away by `.max(0.0)` and are therefore safe — asserted here so
        // the asymmetry is not mistaken for full coverage.
        for bogus in [f32::INFINITY, f32::MAX] {
            let mut t = tb("x");
            t.set_height(bogus);
            let panicked =
                catch_unwind(AssertUnwindSafe(|| drop(t.build_title_style(false)))).is_err();
            assert_eq!(panicked, profile_traps_overflow, "height {bogus} via padding-top");
        }
        for safe in [f32::NEG_INFINITY, f32::MIN] {
            let mut t = tb("x");
            t.set_height(safe);
            assert_eq!(
                padding_top_px(&t.build_title_style(false)),
                None,
                "height {safe} must be clamped away by .max(0.0)",
            );
        }
    }
    // ==================================================================
    // Titlebar::dom (title-only)
    // ==================================================================
    #[test]
    fn dom_builds_the_documented_title_only_tree() {
        let dom = tb("caption").dom();
        assert_eq!(classes(&dom), vec!["csd-titlebar", "__azul-native-titlebar"]);
        assert!(ids(&dom).is_empty(), "the container must not claim an id");
        assert_eq!(dom.children.as_ref().len(), 1, "title-only mode has exactly one child");
        let title = title_node(&dom);
        assert_eq!(classes(title), vec!["csd-title"]);
        assert_eq!(title.children.as_ref().len(), 1);
        assert_eq!(text_of(&title.children.as_ref()[0]), Some("caption"));
        assert!(buttons_node(&dom).is_none(), "title-only mode must render no buttons");
    }
    #[test]
    fn dom_puts_the_container_and_title_styles_on_the_right_nodes() {
        let t = tb("caption");
        let dom = t.clone().dom();
        assert_eq!(inline_props(&dom), expected_container(&t, false));
        assert_eq!(inline_props(title_node(&dom)), expected_title(&t, false));
        // The text node itself carries no styling of its own.
        assert!(inline_props(&title_node(&dom).children.as_ref()[0]).is_empty());
    }
    #[test]
    fn dom_registers_exactly_the_three_drag_callbacks_on_the_title_node() {
        let dom = tb("x").dom();
        assert!(
            callbacks_of(&dom).is_empty(),
            "the container must carry no callbacks — the title node owns the drag",
        );
        assert_eq!(
            callbacks_of(title_node(&dom)),
            vec![
                (
                    EventFilter::Hover(HoverEventFilter::DragStart),
                    callbacks::titlebar_drag_start as usize,
                ),
                (EventFilter::Hover(HoverEventFilter::Drag), callbacks::titlebar_drag as usize),
                (
                    EventFilter::Hover(HoverEventFilter::DoubleClick),
                    callbacks::titlebar_double_click as usize,
                ),
            ],
        );
    }
    #[test]
    fn dom_carries_pathological_titles_into_the_text_node_verbatim() {
        for title in ADVERSARIAL_TITLES {
            let dom = tb(title).dom();
            let text = &title_node(&dom).children.as_ref()[0];
            assert_eq!(text_of(text), Some(title), "the title was mangled on the way in");
            // Even an empty title still gets a text node, so the drag target exists.
            assert_eq!(title_node(&dom).children.as_ref().len(), 1);
        }
    }
    #[test]
    fn dom_keeps_the_cached_child_count_in_sync() {
        // A too-small `estimated_total_children` makes `convert_dom_into_compact_dom`
        // under-allocate and panic on an out-of-bounds write.
        let dom = tb("x").dom();
        assert_eq!(dom.estimated_total_children, count_descendants(&dom));
        assert_eq!(dom.estimated_total_children, 3, "title div + label <p> + text node");
    }
    #[test]
    fn the_dom_conversion_is_exactly_dom() {
        for title in ADVERSARIAL_TITLES {
            let via_from: Dom = tb(title).into();
            assert_eq!(
                fingerprint(&via_from),
                fingerprint(&tb(title).dom()),
                "From<Titlebar> for Dom drifted away from Titlebar::dom",
            );
        }
    }
    // ==================================================================
    // Titlebar::dom_with_buttons / build_button_container
    // ==================================================================
    #[test]
    fn dom_with_buttons_orders_the_children_by_button_side() {
        let buttons = TitlebarButtons::default();
        let left = tb("x").dom_with_buttons(&buttons, TitlebarButtonSide::Left);
        let left_kids = left.children.as_ref();
        assert_eq!(left_kids.len(), 2);
        assert!(has_class(&left_kids[0], "csd-buttons"), "macOS puts the buttons first");
        assert!(has_class(&left_kids[1], "csd-title"));
        let right = tb("x").dom_with_buttons(&buttons, TitlebarButtonSide::Right);
        let right_kids = right.children.as_ref();
        assert_eq!(right_kids.len(), 2);
        assert!(has_class(&right_kids[0], "csd-title"), "Windows/Linux put the title first");
        assert!(has_class(&right_kids[1], "csd-buttons"));
    }
    #[test]
    fn dom_with_buttons_emits_one_node_per_enabled_button_in_minimize_maximize_close_order() {
        for buttons in all_button_combinations() {
            for side in BOTH_SIDES {
                let dom = tb("x").dom_with_buttons(&buttons, side);
                let container = buttons_node(&dom).expect("the CSD button container is mandatory");
                let mut expected: Vec<&str> = Vec::new();
                if buttons.has_minimize {
                    expected.push("csd-button-minimize");
                }
                if buttons.has_maximize {
                    expected.push("csd-button-maximize");
                }
                if buttons.has_close {
                    expected.push("csd-button-close");
                }
                let actual: Vec<String> = container
                    .children
                    .as_ref()
                    .iter()
                    .flat_map(ids)
                    .collect();
                assert_eq!(actual, expected, "{buttons:?} on {side:?} produced the wrong buttons");
            }
        }
    }
    #[test]
    fn has_fullscreen_is_never_rendered() {
        // The flag exists in `TitlebarButtons` but the widget has no fullscreen
        // button; toggling it must not change a single node.
        for &(close, min, max) in &[(true, true, true), (false, false, false), (true, false, true)]
        {
            let off = TitlebarButtons {
                has_close: close,
                has_minimize: min,
                has_maximize: max,
                has_fullscreen: false,
            };
            let on = TitlebarButtons { has_fullscreen: true, ..off };
            assert_eq!(
                fingerprint(&build_button_container(&off)),
                fingerprint(&build_button_container(&on)),
                "has_fullscreen changed the rendered buttons",
            );
        }
    }
    #[test]
    fn all_buttons_disabled_still_emits_an_empty_button_container() {
        let none = TitlebarButtons {
            has_close: false,
            has_minimize: false,
            has_maximize: false,
            has_fullscreen: false,
        };
        let container = build_button_container(&none);
        assert_eq!(classes(&container), vec!["csd-buttons"]);
        assert!(container.children.as_ref().is_empty());
        assert_eq!(container.estimated_total_children, 0);
        // ... and the full DOM still has both children in the documented order.
        let dom = tb("x").dom_with_buttons(&none, TitlebarButtonSide::Right);
        assert_eq!(dom.children.as_ref().len(), 2);
        assert!(buttons_node(&dom).is_some());
    }
    #[test]
    fn every_button_carries_one_mousedown_callback_and_the_matching_icon() {
        let expected: [(&str, &str, usize); 3] = [
            ("csd-button-minimize", "minimize", callbacks::csd_minimize as usize),
            ("csd-button-maximize", "maximize", callbacks::csd_maximize as usize),
            ("csd-button-close", "close", callbacks::csd_close as usize),
        ];
        let container = build_button_container(&TitlebarButtons::default());
        let kids = container.children.as_ref();
        assert_eq!(kids.len(), 3);
        for (node, (id, icon, cb)) in kids.iter().zip(expected) {
            assert_eq!(ids(node), vec![id]);
            assert_eq!(
                callbacks_of(node),
                vec![(EventFilter::Hover(HoverEventFilter::MouseDown), cb)],
                "{id} must carry exactly one MouseDown callback",
            );
            assert_eq!(node.children.as_ref().len(), 1);
            assert_eq!(
                icon_of(&node.children.as_ref()[0]),
                Some(icon),
                "{id} rendered the wrong icon",
            );
        }
    }
    #[test]
    fn every_button_carries_the_shared_and_the_specific_class() {
        let container = build_button_container(&TitlebarButtons::default());
        for (node, specific) in container
            .children
            .as_ref()
            .iter()
            .zip(["csd-minimize", "csd-maximize", "csd-close"])
        {
            assert_eq!(
                classes(node),
                vec!["csd-button".to_string(), specific.to_string()],
                "the stylesheet hooks documented on Titlebar are missing",
            );
        }
    }
    #[test]
    fn dom_with_buttons_keeps_the_cached_child_count_in_sync_for_every_combination() {
        for buttons in all_button_combinations() {
            for side in BOTH_SIDES {
                let dom = tb("x").dom_with_buttons(&buttons, side);
                assert_eq!(
                    dom.estimated_total_children,
                    count_descendants(&dom),
                    "{buttons:?} on {side:?} desynced the cached child count",
                );
                let enabled = usize::from(buttons.has_close)
                    + usize::from(buttons.has_minimize)
                    + usize::from(buttons.has_maximize);
                // title + label <p> + text + button container
                // + 2 nodes per enabled button
                assert_eq!(dom.estimated_total_children, 4 + 2 * enabled);
            }
        }
    }
    #[test]
    fn dom_with_buttons_uses_the_csd_container_and_title_styles() {
        let t = tb("x");
        let dom = t.clone().dom_with_buttons(&TitlebarButtons::default(), TitlebarButtonSide::Right);
        assert_eq!(inline_props(&dom), expected_container(&t, true));
        assert_eq!(inline_props(title_node(&dom)), expected_title(&t, true));
        // The button container is styled entirely from the stylesheet.
        assert!(inline_props(buttons_node(&dom).unwrap()).is_empty());
    }
    #[test]
    fn the_button_side_changes_only_the_child_order() {
        let buttons = TitlebarButtons::default();
        let left = tb("x").dom_with_buttons(&buttons, TitlebarButtonSide::Left);
        let right = tb("x").dom_with_buttons(&buttons, TitlebarButtonSide::Right);
        assert_eq!(inline_props(&left), inline_props(&right));
        assert_eq!(classes(&left), classes(&right));
        assert_eq!(
            fingerprint(title_node(&left)),
            fingerprint(title_node(&right)),
            "the title node must not depend on the button side",
        );
        assert_eq!(
            fingerprint(buttons_node(&left).unwrap()),
            fingerprint(buttons_node(&right).unwrap()),
            "the button container must not depend on the button side",
        );
    }
    #[test]
    fn dom_with_buttons_carries_pathological_titles_verbatim() {
        for title in ADVERSARIAL_TITLES {
            let dom = tb(title).dom_with_buttons(&TitlebarButtons::default(), TitlebarButtonSide::Left);
            let text = &title_node(&dom).children.as_ref()[0];
            assert_eq!(text_of(text), Some(title));
        }
    }
    // ==================================================================
    // callbacks::titlebar_drag_start
    // ==================================================================
    #[test]
    fn drag_start_with_an_unknown_position_hands_the_move_to_the_compositor() {
        // Wayland hides the window position, so the *only* way to move is
        // `xdg_toplevel_move` — the manual loop would be a silent no-op there.
        let (update, changes) = with_callback_info(
            state_with(WindowFrame::Normal, WindowPosition::Uninitialized),
            |info| callbacks::titlebar_drag_start(RefAny::new(()), info),
        );
        assert_eq!(update, Update::DoNothing);
        assert_eq!(interactive_moves(&changes), 1, "the compositor move was not requested");
        assert!(state_writes(&changes).is_empty(), "the native path must not write state");
    }
    #[test]
    fn drag_start_with_a_known_position_takes_the_platform_appropriate_path() {
        // macOS is documented to take the native path even with a known position
        // (performWindowDragWithEvent: is snap- and multi-monitor-aware); X11 and
        // Windows fall through to the manual per-event loop. `cfg!` rather than
        // `#[cfg]` so both branches keep type-checking on every target.
        let (update, changes) = with_callback_info(
            state_with(
                WindowFrame::Normal,
                WindowPosition::Initialized(PhysicalPositionI32::new(100, 200)),
            ),
            |info| callbacks::titlebar_drag_start(RefAny::new(()), info),
        );
        assert_eq!(update, Update::DoNothing);
        if cfg!(target_os = "macos") {
            assert_eq!(interactive_moves(&changes), 1);
        } else {
            assert_eq!(interactive_moves(&changes), 0, "the manual path must not ask the OS");
            assert!(
                changes.is_empty(),
                "a normal-frame manual drag start must record nothing at all",
            );
        }
    }
    #[test]
    fn drag_start_restores_a_maximized_window_before_the_manual_move() {
        // Dragging a maximized window has to un-maximize first, otherwise the
        // manual loop slides the still-maximized frame around the screen.
        let before = state_with(
            WindowFrame::Maximized,
            WindowPosition::Initialized(PhysicalPositionI32::new(0, 0)),
        );
        let (update, changes) = with_callback_info(before.clone(), |info| {
            callbacks::titlebar_drag_start(RefAny::new(()), info)
        });
        assert_eq!(update, Update::DoNothing);
        if cfg!(target_os = "macos") {
            assert_eq!(interactive_moves(&changes), 1);
            assert!(state_writes(&changes).is_empty());
        } else {
            let writes = state_writes(&changes);
            assert_eq!(writes.len(), 1, "the un-maximize write is missing");
            assert_eq!(writes[0].flags.frame, WindowFrame::Normal);
            // Nothing else may be touched on the way through.
            let mut expected = before;
            expected.flags.frame = WindowFrame::Normal;
            assert_eq!(writes[0], expected, "drag start changed more than the frame");
        }
    }
    #[test]
    fn drag_start_leaves_a_fullscreen_or_minimized_frame_alone() {
        for frame in [WindowFrame::Fullscreen, WindowFrame::Minimized, WindowFrame::Normal] {
            let (_, changes) = with_callback_info(
                state_with(frame, WindowPosition::Initialized(PhysicalPositionI32::new(1, 1))),
                |info| callbacks::titlebar_drag_start(RefAny::new(()), info),
            );
            if !cfg!(target_os = "macos") {
                assert!(
                    state_writes(&changes).is_empty(),
                    "{frame:?} must not be rewritten — only Maximized is restored",
                );
            }
        }
    }
    // ==================================================================
    // callbacks::titlebar_drag
    // ==================================================================
    #[test]
    fn drag_without_an_active_gesture_is_a_no_op() {
        // No drag is in flight, so `get_drag_delta_screen_incremental()` is None and
        // the if-let must not match — a callback that moved the window anyway would
        // teleport it on the first stray Drag event.
        for position in [
            WindowPosition::Uninitialized,
            WindowPosition::Initialized(PhysicalPositionI32::new(-5, 7)),
        ] {
            let (update, changes) =
                with_callback_info(state_with(WindowFrame::Normal, position), |info| {
                    callbacks::titlebar_drag(RefAny::new(()), info)
                });
            assert_eq!(update, Update::DoNothing);
            assert!(changes.is_empty(), "{position:?}: a no-delta drag recorded a change");
        }
    }
    #[test]
    fn drag_is_idempotent_when_repeated_without_a_gesture() {
        for _ in 0..4 {
            let (update, changes) = with_callback_info(
                state_with(
                    WindowFrame::Maximized,
                    WindowPosition::Initialized(PhysicalPositionI32::new(i32::MAX, i32::MIN)),
                ),
                |info| callbacks::titlebar_drag(RefAny::new(()), info),
            );
            assert_eq!(update, Update::DoNothing);
            // Extreme coordinates must not tempt the callback into arithmetic it
            // was never asked to do.
            assert!(changes.is_empty());
        }
    }
    // ==================================================================
    // callbacks::titlebar_double_click / csd_maximize
    // ==================================================================
    #[test]
    fn double_click_toggles_maximized_and_normalises_every_other_frame() {
        for frame in ALL_FRAMES {
            let before = state_with(frame, WindowPosition::Uninitialized);
            let (update, changes) = with_callback_info(before.clone(), |info| {
                callbacks::titlebar_double_click(RefAny::new(()), info)
            });
            assert_eq!(update, Update::DoNothing);
            let writes = state_writes(&changes);
            assert_eq!(writes.len(), 1, "{frame:?}: exactly one state write expected");
            let expected_frame = if frame == WindowFrame::Maximized {
                WindowFrame::Normal
            } else {
                WindowFrame::Maximized
            };
            assert_eq!(writes[0].flags.frame, expected_frame, "{frame:?} toggled wrongly");
            let mut expected = before;
            expected.flags.frame = expected_frame;
            assert_eq!(writes[0], expected, "{frame:?}: more than the frame changed");
        }
    }
    #[test]
    fn double_clicking_twice_returns_to_the_original_frame() {
        let (_, changes) = with_callback_info(
            state_with(WindowFrame::Normal, WindowPosition::Uninitialized),
            |info| callbacks::titlebar_double_click(RefAny::new(()), info),
        );
        let once = state_writes(&changes).remove(0);
        assert_eq!(once.flags.frame, WindowFrame::Maximized);
        let (_, changes) = with_callback_info(once, |info| {
            callbacks::titlebar_double_click(RefAny::new(()), info)
        });
        assert_eq!(state_writes(&changes)[0].flags.frame, WindowFrame::Normal);
    }
    #[test]
    fn the_maximize_button_agrees_with_the_double_click_for_every_frame() {
        for frame in ALL_FRAMES {
            let before = state_with(frame, WindowPosition::Uninitialized);
            let (_, via_button) = with_callback_info(before.clone(), |info| {
                callbacks::csd_maximize(RefAny::new(()), info)
            });
            let (_, via_double) = with_callback_info(before, |info| {
                callbacks::titlebar_double_click(RefAny::new(()), info)
            });
            assert_eq!(
                state_writes(&via_button),
                state_writes(&via_double),
                "{frame:?}: the maximize button and the double-click diverged",
            );
        }
    }
    // ==================================================================
    // callbacks::csd_close / csd_minimize
    // ==================================================================
    #[test]
    fn close_sets_close_requested_and_nothing_else() {
        for frame in ALL_FRAMES {
            let before = state_with(frame, WindowPosition::Uninitialized);
            assert!(!before.flags.close_requested, "fixture must start un-closed");
            let (update, changes) =
                with_callback_info(before.clone(), |info| callbacks::csd_close(RefAny::new(()), info));
            assert_eq!(update, Update::DoNothing);
            let writes = state_writes(&changes);
            assert_eq!(writes.len(), 1);
            assert!(writes[0].flags.close_requested);
            assert_eq!(writes[0].flags.frame, frame, "close must not move the frame");
            let mut expected = before;
            expected.flags.close_requested = true;
            assert_eq!(writes[0], expected, "close changed more than close_requested");
        }
    }
    #[test]
    fn close_is_idempotent_on_an_already_closing_window() {
        let mut before = state_with(WindowFrame::Normal, WindowPosition::Uninitialized);
        before.flags.close_requested = true;
        let (_, changes) =
            with_callback_info(before.clone(), |info| callbacks::csd_close(RefAny::new(()), info));
        assert_eq!(state_writes(&changes), vec![before], "a second close must be a re-assert");
    }
    #[test]
    fn minimize_always_minimizes_regardless_of_the_current_frame() {
        for frame in ALL_FRAMES {
            let before = state_with(frame, WindowPosition::Uninitialized);
            let (update, changes) = with_callback_info(before.clone(), |info| {
                callbacks::csd_minimize(RefAny::new(()), info)
            });
            assert_eq!(update, Update::DoNothing);
            let writes = state_writes(&changes);
            assert_eq!(writes.len(), 1);
            assert_eq!(writes[0].flags.frame, WindowFrame::Minimized, "{frame:?} was not minimized");
            let mut expected = before;
            expected.flags.frame = WindowFrame::Minimized;
            assert_eq!(writes[0], expected, "{frame:?}: minimize changed more than the frame");
        }
    }
    #[test]
    fn minimize_never_requests_a_close() {
        let (_, changes) = with_callback_info(
            state_with(WindowFrame::Normal, WindowPosition::Uninitialized),
            |info| callbacks::csd_minimize(RefAny::new(()), info),
        );
        assert!(!state_writes(&changes)[0].flags.close_requested);
        assert_eq!(interactive_moves(&changes), 0);
    }
}