Skip to main content

azul_layout/widgets/
titlebar.rs

1//! Titlebar widget for custom window chrome (CSD and title-only modes).
2//!
3//! Key type: [`Titlebar`]
4
5use 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
10use 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")]
30const DEFAULT_TITLEBAR_HEIGHT: f32 = 28.0;
31#[cfg(target_os = "windows")]
32const DEFAULT_TITLEBAR_HEIGHT: f32 = 32.0;
33#[cfg(target_os = "linux")]
34const DEFAULT_TITLEBAR_HEIGHT: f32 = 30.0;
35#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
36const DEFAULT_TITLEBAR_HEIGHT: f32 = 32.0;
37
38#[cfg(target_os = "macos")]
39const DEFAULT_TITLE_FONT_SIZE: f32 = 13.0;
40#[cfg(target_os = "windows")]
41const DEFAULT_TITLE_FONT_SIZE: f32 = 12.0;
42#[cfg(target_os = "linux")]
43const DEFAULT_TITLE_FONT_SIZE: f32 = 13.0;
44#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
45const DEFAULT_TITLE_FONT_SIZE: f32 = 13.0;
46
47// Verified: macOS 11–15 traffic-light geometry = 78px including gaps
48#[cfg(target_os = "macos")]
49const DEFAULT_BUTTON_AREA_WIDTH: f32 = 78.0;
50// Windows 10/11: 3 buttons x 46px = 138px
51#[cfg(target_os = "windows")]
52const DEFAULT_BUTTON_AREA_WIDTH: f32 = 138.0;
53#[cfg(target_os = "linux")]
54const DEFAULT_BUTTON_AREA_WIDTH: f32 = 100.0;
55#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
56const DEFAULT_BUTTON_AREA_WIDTH: f32 = 100.0;
57
58// macOS: traffic lights on the left.  All others: right.
59#[cfg(target_os = "macos")]
60const DEFAULT_BUTTON_SIDE_LEFT: bool = true;
61#[cfg(not(target_os = "macos"))]
62const DEFAULT_BUTTON_SIDE_LEFT: bool = false;
63
64// Default title text color for light / dark fallback
65const DEFAULT_TITLE_COLOR_LIGHT: ColorU = ColorU { r: 76, g: 76, b: 76, a: 255 };  // #4c4c4c
66const 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)]
105pub 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
120impl 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    #[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        let half = DEFAULT_BUTTON_AREA_WIDTH / 2.0;
130        let (padding_left, padding_right) = (half, half);
131        Self {
132            title,
133            height: DEFAULT_TITLEBAR_HEIGHT,
134            font_size: DEFAULT_TITLE_FONT_SIZE,
135            padding_left,
136            padding_right,
137            title_color: DEFAULT_TITLE_COLOR_LIGHT,
138        }
139    }
140
141    /// FFI-compatible alias for [`Titlebar::new`].
142    #[inline]
143    #[must_use] pub fn create(title: AzString) -> Self {
144        Self::new(title)
145    }
146
147    /// Create a titlebar with a custom height.
148    #[inline]
149    #[must_use] pub fn with_height(title: AzString, height: f32) -> Self {
150        let mut tb = Self::new(title);
151        tb.height = height;
152        tb
153    }
154
155    /// Set the titlebar height.
156    #[inline]
157    pub const fn set_height(&mut self, height: f32) {
158        self.height = height;
159    }
160
161    /// Set the title text.
162    #[inline]
163    pub fn set_title(&mut self, title: AzString) {
164        self.title = title;
165    }
166
167    /// Swap this titlebar with a default instance, returning the old value.
168    #[inline]
169    #[must_use]
170    pub fn swap_with_default(&mut self) -> Self {
171        let mut s = Self::new(AzString::from_const_str(""));
172        core::mem::swap(&mut s, self);
173        s
174    }
175
176    /// Create from a live [`SystemStyle`] (for title-only mode, padding
177    /// reserves space for OS-drawn buttons).
178    #[must_use] pub fn from_system_style(title: AzString, system_style: &SystemStyle) -> Self {
179        let tm = &system_style.metrics.titlebar;
180        let height = tm.height.as_ref()
181            .map_or(DEFAULT_TITLEBAR_HEIGHT, |pv| pv.to_pixels_internal(0.0, 0.0, 0.0));
182        let font_size = tm.title_font_size
183            .into_option()
184            .unwrap_or(DEFAULT_TITLE_FONT_SIZE);
185        let button_area = tm.button_area_width.as_ref()
186            .map_or(DEFAULT_BUTTON_AREA_WIDTH, |pv| pv.to_pixels_internal(0.0, 0.0, 0.0));
187        let safe_left = tm.safe_area.left.as_ref()
188            .map_or(0.0, |pv| pv.to_pixels_internal(0.0, 0.0, 0.0));
189        let safe_right = tm.safe_area.right.as_ref()
190            .map_or(0.0, |pv| pv.to_pixels_internal(0.0, 0.0, 0.0));
191        // Apply padding_horizontal from TitlebarMetrics
192        let pad_h = tm.padding_horizontal.as_ref()
193            .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        let half_btn = button_area / 2.0;
199        let (padding_left, padding_right) = (
200            half_btn + safe_left + pad_h,
201            half_btn + safe_right + pad_h,
202        );
203
204        // Resolve title color from system style, with dark/light fallback
205        let title_color = system_style.colors.text.into_option().unwrap_or(
206            match system_style.theme {
207                system::Theme::Dark => DEFAULT_TITLE_COLOR_DARK,
208                system::Theme::Light => DEFAULT_TITLE_COLOR_LIGHT,
209            }
210        );
211
212        Self { title, height, font_size, padding_left, padding_right, title_color }
213    }
214
215    /// Create from [`SystemStyle`] for **full CSD** mode (no padding - the
216    /// buttons are rendered as DOM children).
217    #[must_use] pub fn from_system_style_csd(title: AzString, system_style: &SystemStyle) -> Self {
218        let tm = &system_style.metrics.titlebar;
219        let height = tm.height.as_ref()
220            .map_or(DEFAULT_TITLEBAR_HEIGHT, |pv| pv.to_pixels_internal(0.0, 0.0, 0.0));
221        let font_size = tm.title_font_size
222            .into_option()
223            .unwrap_or(DEFAULT_TITLE_FONT_SIZE);
224        let title_color = system_style.colors.text.into_option().unwrap_or(
225            match system_style.theme {
226                system::Theme::Dark => DEFAULT_TITLE_COLOR_DARK,
227                system::Theme::Light => DEFAULT_TITLE_COLOR_LIGHT,
228            }
229        );
230        Self { title, height, font_size, padding_left: 0.0, padding_right: 0.0, title_color }
231    }
232
233    /// Build inline CSS for the container div.
234    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
235    fn build_container_style(&self, show_buttons: bool) -> CssPropertyWithConditionsVec {
236        let mut props = Vec::with_capacity(8);
237        if show_buttons {
238            // CSD mode: flex layout to place buttons + title side by side
239            props.push(CssPropertyWithConditions::simple(
240                CssProperty::const_display(LayoutDisplay::Flex),
241            ));
242            props.push(CssPropertyWithConditions::simple(
243                CssProperty::const_flex_direction(LayoutFlexDirection::Row),
244            ));
245            props.push(CssPropertyWithConditions::simple(
246                CssProperty::const_align_items(LayoutAlignItems::Center),
247            ));
248        } else {
249            // Title-only mode: block layout — title fills width automatically.
250            // Avoids flex-grow complexity; text centers via text-align.
251            props.push(CssPropertyWithConditions::simple(
252                CssProperty::const_display(LayoutDisplay::Block),
253            ));
254        }
255        props.push(CssPropertyWithConditions::simple(
256            CssProperty::const_height(LayoutHeight::const_px(self.height as isize)),
257        ));
258        // Titlebar should show grab cursor and prevent text selection
259        props.push(CssPropertyWithConditions::simple(
260            CssProperty::const_cursor(StyleCursor::Grab),
261        ));
262        props.push(CssPropertyWithConditions::simple(
263            CssProperty::user_select(StyleUserSelect::None),
264        ));
265        if self.padding_left > 0.0 {
266            props.push(CssPropertyWithConditions::simple(
267                CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
268                    self.padding_left as isize,
269                )),
270            ));
271        }
272        if self.padding_right > 0.0 {
273            props.push(CssPropertyWithConditions::simple(
274                CssProperty::const_padding_right(LayoutPaddingRight::const_px(
275                    self.padding_right as isize,
276                )),
277            ));
278        }
279        CssPropertyWithConditionsVec::from_vec(props)
280    }
281
282    /// Build inline CSS for the title text node.
283    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
284    fn build_title_style(&self, show_buttons: bool) -> CssPropertyWithConditionsVec {
285        let font_family = StyleFontFamilyVec::from_vec(vec![
286            StyleFontFamily::SystemType(SystemFontType::TitleBold),
287        ]);
288        let mut props = Vec::with_capacity(10);
289        props.push(CssPropertyWithConditions::simple(
290            CssProperty::const_font_size(StyleFontSize::const_px(self.font_size as isize)),
291        ));
292        props.push(CssPropertyWithConditions::simple(
293            CssProperty::const_font_family(font_family),
294        ));
295        // Use resolved title color from SystemStyle (adapts to dark mode)
296        props.push(CssPropertyWithConditions::simple(
297            CssProperty::const_text_color(StyleTextColor { inner: self.title_color }),
298        ));
299        // In CSD mode (flex container), title must grow to fill remaining space
300        if show_buttons {
301            props.push(CssPropertyWithConditions::simple(
302                CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1)),
303            ));
304            props.push(CssPropertyWithConditions::simple(
305                CssProperty::const_min_width(LayoutMinWidth::const_px(0)),
306            ));
307        }
308        props.push(CssPropertyWithConditions::simple(
309            CssProperty::const_text_align(StyleTextAlign::Center),
310        ));
311        props.push(CssPropertyWithConditions::simple(
312            CssProperty::WhiteSpace(StyleWhiteSpaceValue::Exact(StyleWhiteSpace::Nowrap)),
313        ));
314        props.push(CssPropertyWithConditions::simple(
315            CssProperty::const_overflow_x(LayoutOverflow::Hidden),
316        ));
317        // Vertically center the text: pad from top by (height - font_size) / 2
318        let v_pad = ((self.height - self.font_size) / 2.0).max(0.0);
319        if v_pad > 0.0 {
320            props.push(CssPropertyWithConditions::simple(
321                CssProperty::const_padding_top(LayoutPaddingTop::const_px(v_pad as isize)),
322            ));
323        }
324        CssPropertyWithConditionsVec::from_vec(props)
325    }
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    #[must_use] pub fn dom(self) -> Dom {
333        self.dom_inner(false, &TitlebarButtons::default(), TitlebarButtonSide::Right)
334    }
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    #[must_use] pub fn dom_with_buttons(
341        self,
342        buttons: &TitlebarButtons,
343        button_side: TitlebarButtonSide,
344    ) -> Dom {
345        self.dom_inner(true, buttons, button_side)
346    }
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    fn dom_inner(
351        self,
352        show_buttons: bool,
353        buttons: &TitlebarButtons,
354        button_side: TitlebarButtonSide,
355    ) -> 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        let title_style = self.build_title_style(show_buttons);
366        let container_style = self.build_container_style(show_buttons);
367
368        // ── Title node with drag callbacks ──
369        let title_classes = IdOrClassVec::from_vec(vec![Class("csd-title".into())]);
370
371        let title_node = Dom::create_div()
372            .with_ids_and_classes(title_classes)
373            .with_css_props(title_style)
374            .with_child(Dom::create_text(self.title)) // moves self.title
375            .with_callbacks(vec![
376                CoreCallbackData {
377                    event: EventFilter::Hover(HoverEventFilter::DragStart),
378                    callback: CoreCallback {
379                        cb: callbacks::titlebar_drag_start as usize,
380                        ctx: azul_core::refany::OptionRefAny::None,
381                    },
382                    refany: RefAny::new(DragMarker),
383                },
384                CoreCallbackData {
385                    event: EventFilter::Hover(HoverEventFilter::Drag),
386                    callback: CoreCallback {
387                        cb: callbacks::titlebar_drag as usize,
388                        ctx: azul_core::refany::OptionRefAny::None,
389                    },
390                    refany: RefAny::new(DragMarker),
391                },
392                CoreCallbackData {
393                    event: EventFilter::Hover(HoverEventFilter::DoubleClick),
394                    callback: CoreCallback {
395                        cb: callbacks::titlebar_double_click as usize,
396                        ctx: azul_core::refany::OptionRefAny::None,
397                    },
398                    refany: RefAny::new(DragMarker),
399                },
400            ].into());
401
402        // ── Button container (CSD mode only) ──
403        let button_container = if show_buttons {
404            Some(build_button_container(buttons))
405        } else {
406            None
407        };
408
409        // ── Root ──
410        let container_classes = IdOrClassVec::from_vec(vec![
411            Class("csd-titlebar".into()),
412            Class("__azul-native-titlebar".into()),
413        ]);
414        let mut root = Dom::create_div()
415            .with_ids_and_classes(container_classes)
416            .with_css_props(container_style);
417
418        // Button side determines child order:
419        //   Left  (macOS):   [buttons] [title]
420        //   Right (Win/Lin): [title] [buttons]
421        match button_side {
422            TitlebarButtonSide::Left => {
423                if let Some(btn) = button_container { root = root.with_child(btn); }
424                root = root.with_child(title_node);
425            }
426            TitlebarButtonSide::Right => {
427                root = root.with_child(title_node);
428                if let Some(btn) = button_container { root = root.with_child(btn); }
429            }
430        }
431
432        root
433    }
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)
438fn build_button_container(buttons: &TitlebarButtons) -> Dom {
439    use azul_core::{
440        callbacks::{CoreCallback, CoreCallbackData},
441        dom::{EventFilter, HoverEventFilter},
442    };
443
444    let mut children = Vec::new();
445
446    if buttons.has_minimize {
447        let classes = IdOrClassVec::from_vec(vec![
448            Id("csd-button-minimize".into()),
449            Class("csd-button".into()),
450            Class("csd-minimize".into()),
451        ]);
452        children.push(Dom::create_div()
453            .with_ids_and_classes(classes)
454            .with_child(Dom::create_icon("minimize"))
455            .with_callbacks(vec![CoreCallbackData {
456                event: EventFilter::Hover(HoverEventFilter::MouseDown),
457                callback: CoreCallback {
458                    cb: callbacks::csd_minimize as usize,
459                    ctx: azul_core::refany::OptionRefAny::None,
460                },
461                refany: RefAny::new(()),
462            }].into()));
463    }
464
465    if buttons.has_maximize {
466        let classes = IdOrClassVec::from_vec(vec![
467            Id("csd-button-maximize".into()),
468            Class("csd-button".into()),
469            Class("csd-maximize".into()),
470        ]);
471        children.push(Dom::create_div()
472            .with_ids_and_classes(classes)
473            .with_child(Dom::create_icon("maximize"))
474            .with_callbacks(vec![CoreCallbackData {
475                event: EventFilter::Hover(HoverEventFilter::MouseDown),
476                callback: CoreCallback {
477                    cb: callbacks::csd_maximize as usize,
478                    ctx: azul_core::refany::OptionRefAny::None,
479                },
480                refany: RefAny::new(()),
481            }].into()));
482    }
483
484    if buttons.has_close {
485        let classes = IdOrClassVec::from_vec(vec![
486            Id("csd-button-close".into()),
487            Class("csd-button".into()),
488            Class("csd-close".into()),
489        ]);
490        children.push(Dom::create_div()
491            .with_ids_and_classes(classes)
492            .with_child(Dom::create_icon("close"))
493            .with_callbacks(vec![CoreCallbackData {
494                event: EventFilter::Hover(HoverEventFilter::MouseDown),
495                callback: CoreCallback {
496                    cb: callbacks::csd_close as usize,
497                    ctx: azul_core::refany::OptionRefAny::None,
498                },
499                refany: RefAny::new(()),
500            }].into()));
501    }
502
503    let classes = IdOrClassVec::from_vec(vec![Class("csd-buttons".into())]);
504    Dom::create_div()
505        .with_ids_and_classes(classes)
506        .with_children(DomVec::from_vec(children))
507}
508
509impl From<Titlebar> for Dom {
510    fn from(t: Titlebar) -> Self { t.dom() }
511}
512
513impl Default for Titlebar {
514    fn default() -> Self {
515        Self::new(AzString::from_const_str(""))
516    }
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.
525pub(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    pub(super) extern "C" fn titlebar_drag_start(
533        _data: RefAny, mut info: CallbackInfo,
534    ) -> 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        let ws = info.get_current_window_state().clone();
543        let native_move = matches!(ws.position, azul_core::window::WindowPosition::Uninitialized)
544            || cfg!(target_os = "macos");
545        if native_move {
546            info.begin_interactive_move();
547        } else {
548            // MWA-C-csd: reset the fractional-residual accumulator for the
549            // manual move loop (see titlebar_drag).
550            RESIDUAL_X_BITS.store(0f32.to_bits(), core::sync::atomic::Ordering::Relaxed);
551            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            if ws.flags.frame == azul_core::window::WindowFrame::Maximized {
556                let mut s = ws;
557                s.flags.frame = azul_core::window::WindowFrame::Normal;
558                info.modify_window_state(s);
559            }
560        }
561        Update::DoNothing
562    }
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    pub(super) extern "C" fn titlebar_drag(
586        _data: RefAny, mut info: CallbackInfo,
587    ) -> Update {
588        use azul_core::window::WindowPosition;
589        use azul_core::geom::PhysicalPositionI32;
590
591        let delta = info.get_drag_delta_screen_incremental();
592        let current_pos = info.get_current_window_state().position;
593
594        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        }
614        // On Wayland: current_pos is Uninitialized, so the if-let doesn't match → no-op.
615        Update::DoNothing
616    }
617
618    /// `DoubleClick` - toggle Maximized ↔ Normal.
619    pub(super) extern "C" fn titlebar_double_click(
620        _data: RefAny, mut info: CallbackInfo,
621    ) -> Update {
622        use azul_core::window::WindowFrame;
623        let mut s = info.get_current_window_state().clone();
624        s.flags.frame = if s.flags.frame == WindowFrame::Maximized {
625            WindowFrame::Normal } else { WindowFrame::Maximized };
626        info.modify_window_state(s);
627        Update::DoNothing
628    }
629
630    /// Close button - `close_requested = true`.
631    pub(super) extern "C" fn csd_close(
632        _data: RefAny, mut info: CallbackInfo,
633    ) -> Update {
634        let mut s = info.get_current_window_state().clone();
635        s.flags.close_requested = true;
636        info.modify_window_state(s);
637        Update::DoNothing
638    }
639
640    /// Minimize button - `frame = Minimized`.
641    pub(super) extern "C" fn csd_minimize(
642        _data: RefAny, mut info: CallbackInfo,
643    ) -> Update {
644        use azul_core::window::WindowFrame;
645        let mut s = info.get_current_window_state().clone();
646        s.flags.frame = WindowFrame::Minimized;
647        info.modify_window_state(s);
648        Update::DoNothing
649    }
650
651    /// Maximize button - toggle Maximized ↔ Normal.
652    pub(super) extern "C" fn csd_maximize(
653        _data: RefAny, mut info: CallbackInfo,
654    ) -> Update {
655        use azul_core::window::WindowFrame;
656        let mut s = info.get_current_window_state().clone();
657        s.flags.frame = if s.flags.frame == WindowFrame::Maximized {
658            WindowFrame::Normal } else { WindowFrame::Maximized };
659        info.modify_window_state(s);
660        Update::DoNothing
661    }
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)]
672mod 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, IdOrClass::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                IdOrClass::Id(s) => Some(s.as_str().to_string()),
906                IdOrClass::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                IdOrClass::Class(s) => Some(s.as_str().to_string()),
919                IdOrClass::Id(_) => None,
920            })
921            .collect()
922    }
923
924    /// The text of a `NodeType::Text` node (`None` for any other node type).
925    fn text_of(node: &Dom) -> Option<&str> {
926        match node.root.get_node_type() {
927            NodeType::Text(s) => Some(s.as_ref().as_str()),
928            _ => None,
929        }
930    }
931
932    /// The icon name of a `NodeType::Icon` node.
933    fn icon_of(node: &Dom) -> Option<&str> {
934        match node.root.get_node_type() {
935            NodeType::Icon(s) => Some(s.as_ref().as_str()),
936            _ => None,
937        }
938    }
939
940    /// A node's *inline* style properties, in declaration order.
941    fn inline_props(node: &Dom) -> Vec<CssProperty> {
942        node.root.style.iter_inline_properties().map(|(p, _)| p.clone()).collect()
943    }
944
945    /// `(event, callback fn address)` for every callback on `node`, in order.
946    fn callbacks_of(node: &Dom) -> Vec<(EventFilter, usize)> {
947        node.root
948            .get_callbacks()
949            .as_ref()
950            .iter()
951            .map(|c| (c.event, c.callback.cb))
952            .collect()
953    }
954
955    /// The recursive descendant count. `Dom::estimated_total_children` is a
956    /// *cached* value that, if too small, makes `convert_dom_into_compact_dom`
957    /// under-allocate its arenas and panic on out-of-bounds writes — so it has to
958    /// match this exactly for every button combination.
959    fn count_descendants(dom: &Dom) -> usize {
960        dom.children.as_ref().iter().map(|c| 1 + count_descendants(c)).sum()
961    }
962
963    /// A pre-order, structural fingerprint of a DOM: node type, ids/classes,
964    /// `(event, fn address)` per callback and inline declarations. Used instead of
965    /// `Dom: PartialEq` because the drag callbacks carry freshly allocated
966    /// `RefAny`s, which compare by pointer and so are never equal across builds.
967    fn fingerprint(dom: &Dom) -> Vec<String> {
968        fn walk(d: &Dom, depth: usize, out: &mut Vec<String>) {
969            out.push(format!(
970                "{depth}|{:?}|{:?}|{:?}|{:?}|{:?}",
971                d.root.get_node_type(),
972                ids(d),
973                classes(d),
974                callbacks_of(d),
975                inline_props(d),
976            ));
977            for c in d.children.as_ref() {
978                walk(c, depth + 1, out);
979            }
980        }
981        let mut out = Vec::new();
982        walk(dom, 0, &mut out);
983        out
984    }
985
986    /// The title node of a rendered titlebar (the `.csd-title` div).
987    fn title_node(dom: &Dom) -> &Dom {
988        dom.children
989            .as_ref()
990            .iter()
991            .find(|c| has_class(c, "csd-title"))
992            .expect("every titlebar must render a .csd-title node")
993    }
994
995    /// The `.csd-buttons` node of a rendered titlebar, if there is one.
996    fn buttons_node(dom: &Dom) -> Option<&Dom> {
997        dom.children.as_ref().iter().find(|c| has_class(c, "csd-buttons"))
998    }
999
1000    fn all_button_combinations() -> Vec<TitlebarButtons> {
1001        let mut out = Vec::new();
1002        for &close in &[false, true] {
1003            for &min in &[false, true] {
1004                for &max in &[false, true] {
1005                    for &full in &[false, true] {
1006                        out.push(TitlebarButtons {
1007                            has_close: close,
1008                            has_minimize: min,
1009                            has_maximize: max,
1010                            has_fullscreen: full,
1011                        });
1012                    }
1013                }
1014            }
1015        }
1016        out
1017    }
1018
1019    /// A `SystemStyle` whose titlebar metrics are all "not detected" — the state
1020    /// `SystemStyle::default()` ships and the one the fallbacks exist for.
1021    fn blank_system_style() -> SystemStyle {
1022        SystemStyle::default()
1023    }
1024
1025    /// Runs `f` against a `CallbackInfo` backed by `state`, returning `f`'s result
1026    /// plus every recorded `CallbackChange`. No layout result is inserted: none of
1027    /// the titlebar callbacks walk the DOM.
1028    fn with_callback_info<R>(
1029        state: FullWindowState,
1030        f: impl FnOnce(CallbackInfo) -> R,
1031    ) -> (R, Vec<CallbackChange>) {
1032        let layout_window =
1033            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
1034
1035        let renderer_resources = RendererResources::default();
1036        let previous_window_state: Option<FullWindowState> = None;
1037        let current_window_state = state;
1038        let gl_context = OptionGlContextPtr::None;
1039        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
1040            BTreeMap::new();
1041        let window_handle = RawWindowHandle::Unsupported;
1042        let system_callbacks = ExternalSystemCallbacks::rust_internal();
1043
1044        let ref_data = CallbackInfoRefData {
1045            layout_window: &layout_window,
1046            renderer_resources: &renderer_resources,
1047            previous_window_state: &previous_window_state,
1048            current_window_state: &current_window_state,
1049            gl_context: &gl_context,
1050            current_scroll_manager: &scroll_states,
1051            current_window_handle: &window_handle,
1052            system_callbacks: &system_callbacks,
1053            system_style: Arc::new(SystemStyle::default()),
1054            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
1055            #[cfg(feature = "icu")]
1056            icu_localizer: IcuLocalizerHandle::default(),
1057            ctx: OptionRefAny::None,
1058        };
1059
1060        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
1061
1062        let info = CallbackInfo::new(
1063            &ref_data,
1064            &changes,
1065            DomNodeId {
1066                dom: DomId::ROOT_ID,
1067                node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(0))),
1068            },
1069            OptionLogicalPosition::None,
1070            OptionLogicalPosition::None,
1071        );
1072
1073        let out = f(info);
1074        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
1075        (out, recorded)
1076    }
1077
1078    /// The window states pushed through `modify_window_state`, in order.
1079    fn state_writes(changes: &[CallbackChange]) -> Vec<FullWindowState> {
1080        changes
1081            .iter()
1082            .filter_map(|c| match c {
1083                CallbackChange::ModifyWindowState { state } => Some(state.clone()),
1084                _ => None,
1085            })
1086            .collect()
1087    }
1088
1089    fn interactive_moves(changes: &[CallbackChange]) -> usize {
1090        changes
1091            .iter()
1092            .filter(|c| matches!(c, CallbackChange::BeginInteractiveMove))
1093            .count()
1094    }
1095
1096    fn state_with(frame: WindowFrame, position: WindowPosition) -> FullWindowState {
1097        let mut s = FullWindowState::default();
1098        s.flags.frame = frame;
1099        s.position = position;
1100        s
1101    }
1102
1103    // ==================================================================
1104    // Titlebar::new / Titlebar::create / Default
1105    // ==================================================================
1106
1107    #[test]
1108    fn new_uses_the_compile_time_platform_defaults() {
1109        let t = tb("hello");
1110
1111        assert_eq!(t.title.as_str(), "hello");
1112        assert_eq!(t.height, DEFAULT_TITLEBAR_HEIGHT);
1113        assert_eq!(t.font_size, DEFAULT_TITLE_FONT_SIZE);
1114        assert_eq!(t.title_color, DEFAULT_TITLE_COLOR_LIGHT);
1115        assert_eq!(t.padding_left, DEFAULT_BUTTON_AREA_WIDTH / 2.0);
1116        assert_eq!(t.padding_right, DEFAULT_BUTTON_AREA_WIDTH / 2.0);
1117    }
1118
1119    #[test]
1120    fn new_pads_both_sides_equally_so_centering_lands_at_the_window_midpoint() {
1121        // The doc comment is explicit: the button-side half clears the OS buttons,
1122        // the opposite half balances `text-align: center`. Asymmetric padding would
1123        // push the title off the window midpoint.
1124        let t = tb("x");
1125        assert_eq!(
1126            t.padding_left, t.padding_right,
1127            "title-only padding must stay symmetric",
1128        );
1129        assert!(t.padding_left >= 0.0, "negative reserved space is meaningless");
1130        assert!(t.height > 0.0 && t.height.is_finite());
1131        assert!(t.font_size > 0.0 && t.font_size.is_finite());
1132        assert!(
1133            t.font_size < t.height,
1134            "the default font must fit inside the default titlebar height",
1135        );
1136    }
1137
1138    #[test]
1139    fn new_stores_pathological_titles_byte_for_byte() {
1140        for title in ADVERSARIAL_TITLES {
1141            let t = tb(title);
1142            assert_eq!(t.title.as_str(), title, "the title was mangled or normalised");
1143            assert_eq!(
1144                t.title.as_str().len(),
1145                title.len(),
1146                "the title was truncated (an embedded NUL must not terminate it)",
1147            );
1148        }
1149    }
1150
1151    #[test]
1152    fn new_accepts_a_title_far_longer_than_any_real_window_caption() {
1153        let huge = "a".repeat(1_000_000);
1154        let t = Titlebar::new(AzString::from(huge.clone()));
1155        assert_eq!(t.title.as_str().len(), 1_000_000);
1156        // ... and it survives the trip into the DOM without being re-encoded.
1157        let dom = t.dom();
1158        assert_eq!(text_of(&title_node(&dom).children.as_ref()[0]), Some(huge.as_str()));
1159    }
1160
1161    #[test]
1162    fn create_is_indistinguishable_from_new() {
1163        for title in ADVERSARIAL_TITLES {
1164            assert_eq!(
1165                Titlebar::create(AzString::from(title)),
1166                Titlebar::new(AzString::from(title)),
1167                "the FFI alias drifted away from Titlebar::new",
1168            );
1169        }
1170    }
1171
1172    #[test]
1173    fn default_is_new_with_an_empty_title() {
1174        let d = Titlebar::default();
1175        assert_eq!(d, tb(""));
1176        assert_eq!(d.title.as_str(), "");
1177    }
1178
1179    // ==================================================================
1180    // Titlebar::with_height / Titlebar::set_height
1181    // ==================================================================
1182
1183    #[test]
1184    fn with_height_stores_every_float_bit_exactly_and_touches_nothing_else() {
1185        let base = tb("t");
1186        for h in TAME_FLOATS.into_iter().chain(UNENCODABLE_FLOATS) {
1187            let t = Titlebar::with_height(AzString::from("t"), h);
1188
1189            // to_bits, not `==`: NaN != NaN, and -0.0 == 0.0 would hide a sign flip.
1190            assert_eq!(
1191                t.height.to_bits(),
1192                h.to_bits(),
1193                "with_height({h}) did not store the value verbatim",
1194            );
1195            assert_eq!(t.title.as_str(), "t");
1196            assert_eq!(t.font_size, base.font_size, "with_height({h}) moved the font size");
1197            assert_eq!(t.padding_left, base.padding_left, "with_height({h}) moved the padding");
1198            assert_eq!(t.padding_right, base.padding_right, "with_height({h}) moved the padding");
1199            assert_eq!(t.title_color, base.title_color, "with_height({h}) moved the colour");
1200        }
1201    }
1202
1203    #[test]
1204    fn set_height_is_a_bit_exact_last_write_wins_store() {
1205        let mut t = tb("t");
1206        let base = tb("t");
1207
1208        for h in TAME_FLOATS.into_iter().chain(UNENCODABLE_FLOATS) {
1209            t.set_height(h);
1210            assert_eq!(t.height.to_bits(), h.to_bits(), "set_height({h}) was not verbatim");
1211            assert_eq!(t.font_size, base.font_size);
1212            assert_eq!(t.padding_left, base.padding_left);
1213            assert_eq!(t.padding_right, base.padding_right);
1214            assert_eq!(t.title.as_str(), "t", "set_height({h}) disturbed the title");
1215        }
1216
1217        // The last write is the one that survives; nothing accumulates.
1218        t.set_height(7.5);
1219        t.set_height(9.25);
1220        assert_eq!(t.height, 9.25);
1221    }
1222
1223    #[test]
1224    fn set_height_zero_and_negative_are_stored_not_clamped() {
1225        // The setter is documented as a plain store — it is `build_container_style`
1226        // that has to survive the result, not the setter.
1227        let mut t = tb("t");
1228
1229        t.set_height(0.0);
1230        assert_eq!(t.height.to_bits(), 0_f32.to_bits(), "0.0 must stay +0.0");
1231
1232        t.set_height(-0.0);
1233        assert_eq!(t.height.to_bits(), (-0.0_f32).to_bits(), "-0.0 must not be normalised");
1234
1235        t.set_height(-42.0);
1236        assert_eq!(t.height, -42.0, "a negative height must not be clamped by the setter");
1237    }
1238
1239    // ==================================================================
1240    // Titlebar::set_title
1241    // ==================================================================
1242
1243    #[test]
1244    fn set_title_replaces_the_title_and_leaves_the_geometry_alone() {
1245        let mut t = Titlebar::with_height(AzString::from("first"), 44.0);
1246        for title in ADVERSARIAL_TITLES {
1247            t.set_title(AzString::from(title));
1248            assert_eq!(t.title.as_str(), title);
1249            assert_eq!(t.title.as_str().len(), title.len());
1250            assert_eq!(t.height, 44.0, "set_title moved the height");
1251            assert_eq!(t.padding_left, tb("").padding_left, "set_title moved the padding");
1252        }
1253    }
1254
1255    // ==================================================================
1256    // Titlebar::swap_with_default
1257    // ==================================================================
1258
1259    #[test]
1260    fn swap_with_default_hands_back_the_old_value_and_leaves_a_default() {
1261        let mut t = Titlebar::with_height(AzString::from("payload"), 99.5);
1262        t.title_color = ColorU { r: 1, g: 2, b: 3, a: 4 };
1263
1264        let taken = t.swap_with_default();
1265
1266        assert_eq!(taken.title.as_str(), "payload", "the title did not travel out");
1267        assert_eq!(taken.height, 99.5, "the height did not travel out");
1268        assert_eq!(taken.title_color, ColorU { r: 1, g: 2, b: 3, a: 4 });
1269
1270        assert_eq!(t, Titlebar::default(), "what was left behind is not a default titlebar");
1271        assert_eq!(t.title.as_str(), "");
1272    }
1273
1274    #[test]
1275    fn swap_with_default_moves_a_nan_height_out_without_losing_its_bits() {
1276        // `Titlebar` derives PartialEq, so a NaN height makes the struct
1277        // self-unequal — the swap still has to move the exact bit pattern.
1278        let mut t = tb("x");
1279        t.set_height(f32::NAN);
1280
1281        let taken = t.swap_with_default();
1282
1283        assert!(taken.height.is_nan(), "the NaN height did not travel out");
1284        assert_eq!(t.height, DEFAULT_TITLEBAR_HEIGHT, "the leftover kept the NaN");
1285        assert_eq!(t, Titlebar::default());
1286    }
1287
1288    #[test]
1289    fn repeated_swap_with_default_never_accumulates_state() {
1290        let mut t = Titlebar::with_height(AzString::from("x"), 1.0);
1291        let _first = t.swap_with_default();
1292
1293        for i in 0..8 {
1294            let taken = t.swap_with_default();
1295            assert_eq!(taken, Titlebar::default(), "swap #{i} handed back a non-default");
1296            assert_eq!(t, Titlebar::default(), "swap #{i} left a non-default behind");
1297        }
1298
1299        // The drained titlebar still renders a well-formed DOM.
1300        let dom = t.dom();
1301        assert_eq!(dom.children.as_ref().len(), 1);
1302        assert_eq!(text_of(&title_node(&dom).children.as_ref()[0]), Some(""));
1303    }
1304
1305    // ==================================================================
1306    // Titlebar::from_system_style
1307    // ==================================================================
1308
1309    #[test]
1310    fn from_system_style_falls_back_to_the_compile_time_defaults_when_nothing_is_detected() {
1311        let ss = blank_system_style();
1312        let t = Titlebar::from_system_style(AzString::from("sys"), &ss);
1313
1314        assert_eq!(t.title.as_str(), "sys");
1315        assert_eq!(t.height, DEFAULT_TITLEBAR_HEIGHT, "an undetected height must fall back");
1316        // `TitlebarMetrics::default()` *does* carry a font size (13.0), so the
1317        // compile-time default is only reachable when it is explicitly None.
1318        assert_eq!(t.font_size, 13.0);
1319        assert_eq!(t.padding_left, DEFAULT_BUTTON_AREA_WIDTH / 2.0);
1320        assert_eq!(t.padding_right, DEFAULT_BUTTON_AREA_WIDTH / 2.0);
1321        assert_eq!(t.title_color, DEFAULT_TITLE_COLOR_LIGHT);
1322    }
1323
1324    #[test]
1325    fn from_system_style_with_no_font_size_falls_back_to_the_platform_constant() {
1326        let mut ss = blank_system_style();
1327        ss.metrics.titlebar.title_font_size = OptionF32::None;
1328        let t = Titlebar::from_system_style(AzString::from("x"), &ss);
1329        assert_eq!(t.font_size, DEFAULT_TITLE_FONT_SIZE);
1330    }
1331
1332    #[test]
1333    fn from_system_style_adds_the_safe_area_and_padding_to_each_side_separately() {
1334        let mut ss = blank_system_style();
1335        ss.metrics.titlebar.button_area_width = OptionPixelValue::Some(PixelValue::px(100.0));
1336        ss.metrics.titlebar.padding_horizontal = OptionPixelValue::Some(PixelValue::px(5.0));
1337        ss.metrics.titlebar.safe_area = SafeAreaInsets {
1338            top: OptionPixelValue::None,
1339            bottom: OptionPixelValue::None,
1340            left: OptionPixelValue::Some(PixelValue::px(10.0)),
1341            right: OptionPixelValue::Some(PixelValue::px(20.0)),
1342        };
1343
1344        let t = Titlebar::from_system_style(AzString::from("x"), &ss);
1345
1346        assert_eq!(t.padding_left, 50.0 + 10.0 + 5.0);
1347        assert_eq!(t.padding_right, 50.0 + 20.0 + 5.0);
1348        // A notch on one side is exactly the documented case where the padding is
1349        // deliberately *not* symmetric.
1350        assert_ne!(t.padding_left, t.padding_right);
1351    }
1352
1353    #[test]
1354    fn from_system_style_reads_the_height_and_font_size_it_was_given() {
1355        let mut ss = blank_system_style();
1356        ss.metrics.titlebar.height = OptionPixelValue::Some(PixelValue::px(41.0));
1357        ss.metrics.titlebar.title_font_size = OptionF32::Some(17.5);
1358
1359        let t = Titlebar::from_system_style(AzString::from("x"), &ss);
1360
1361        assert_eq!(t.height, 41.0);
1362        assert_eq!(t.font_size, 17.5);
1363    }
1364
1365    #[test]
1366    fn from_system_style_converts_absolute_units_but_collapses_relative_ones_to_zero() {
1367        // `to_pixels_internal(0.0, 0.0, 0.0)` is called with *zero* resolution
1368        // bases, so anything relative (em/rem/%/vw) silently resolves to 0px —
1369        // a titlebar height declared in `em` collapses the whole chrome.
1370        let cases: [(PixelValue, f32); 6] = [
1371            (PixelValue::px(30.0), 30.0),
1372            (PixelValue::pt(30.0), 30.0 * (96.0 / 72.0)),
1373            (PixelValue::em(2.0), 0.0),
1374            (PixelValue::rem(2.0), 0.0),
1375            (PixelValue::percent(50.0), 0.0),
1376            (PixelValue::const_from_metric(SizeMetric::Vh, 50), 0.0),
1377        ];
1378
1379        for (pv, expected) in cases {
1380            let mut ss = blank_system_style();
1381            ss.metrics.titlebar.height = OptionPixelValue::Some(pv);
1382            let t = Titlebar::from_system_style(AzString::from("x"), &ss);
1383            assert!(
1384                (t.height - expected).abs() < 0.01,
1385                "{pv:?} resolved to {} px, expected {expected} px",
1386                t.height,
1387            );
1388        }
1389    }
1390
1391    #[test]
1392    fn from_system_style_saturates_a_non_finite_metric_instead_of_propagating_it() {
1393        // `PixelValue::px(inf)` encodes as `f32_to_isize(inf * 1000) == isize::MAX`,
1394        // so what comes back out is huge but *finite*: an infinity reaching the
1395        // layout solver would poison every downstream size computation.
1396        for bogus in [f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, f32::NAN] {
1397            let mut ss = blank_system_style();
1398            ss.metrics.titlebar.height = OptionPixelValue::Some(PixelValue::px(bogus));
1399            ss.metrics.titlebar.button_area_width = OptionPixelValue::Some(PixelValue::px(bogus));
1400
1401            let t = Titlebar::from_system_style(AzString::from("x"), &ss);
1402
1403            assert!(t.height.is_finite(), "{bogus} produced a non-finite height {}", t.height);
1404            assert!(
1405                t.padding_left.is_finite() && t.padding_right.is_finite(),
1406                "{bogus} produced non-finite padding",
1407            );
1408        }
1409
1410        // NaN specifically collapses to zero rather than staying NaN.
1411        let mut ss = blank_system_style();
1412        ss.metrics.titlebar.height = OptionPixelValue::Some(PixelValue::px(f32::NAN));
1413        assert_eq!(Titlebar::from_system_style(AzString::from("x"), &ss).height, 0.0);
1414    }
1415
1416    #[test]
1417    fn from_system_style_prefers_the_detected_text_colour_over_both_theme_fallbacks() {
1418        let detected = ColorU { r: 9, g: 8, b: 7, a: 6 };
1419        for theme in [system::Theme::Light, system::Theme::Dark] {
1420            let mut ss = blank_system_style();
1421            ss.theme = theme;
1422            ss.colors.text = OptionColorU::Some(detected);
1423            assert_eq!(
1424                Titlebar::from_system_style(AzString::from("x"), &ss).title_color,
1425                detected,
1426                "{theme:?}: the detected system text colour must win",
1427            );
1428        }
1429    }
1430
1431    #[test]
1432    fn from_system_style_picks_the_theme_appropriate_fallback_colour() {
1433        let mut light = blank_system_style();
1434        light.theme = system::Theme::Light;
1435        light.colors.text = OptionColorU::None;
1436        assert_eq!(
1437            Titlebar::from_system_style(AzString::from("x"), &light).title_color,
1438            DEFAULT_TITLE_COLOR_LIGHT,
1439        );
1440
1441        let mut dark = blank_system_style();
1442        dark.theme = system::Theme::Dark;
1443        dark.colors.text = OptionColorU::None;
1444        assert_eq!(
1445            Titlebar::from_system_style(AzString::from("x"), &dark).title_color,
1446            DEFAULT_TITLE_COLOR_DARK,
1447        );
1448
1449        // The two fallbacks must actually differ, or dark mode renders unreadably.
1450        assert_ne!(DEFAULT_TITLE_COLOR_LIGHT, DEFAULT_TITLE_COLOR_DARK);
1451    }
1452
1453    #[test]
1454    fn from_system_style_carries_pathological_titles_through_untouched() {
1455        let ss = blank_system_style();
1456        for title in ADVERSARIAL_TITLES {
1457            let t = Titlebar::from_system_style(AzString::from(title), &ss);
1458            assert_eq!(t.title.as_str(), title);
1459            let csd = Titlebar::from_system_style_csd(AzString::from(title), &ss);
1460            assert_eq!(csd.title.as_str(), title);
1461        }
1462    }
1463
1464    // ==================================================================
1465    // Titlebar::from_system_style_csd
1466    // ==================================================================
1467
1468    #[test]
1469    fn from_system_style_csd_zeroes_the_padding_and_keeps_everything_else() {
1470        let mut ss = blank_system_style();
1471        ss.metrics.titlebar.height = OptionPixelValue::Some(PixelValue::px(41.0));
1472        ss.metrics.titlebar.title_font_size = OptionF32::Some(17.5);
1473        ss.theme = system::Theme::Dark;
1474
1475        let title_only = Titlebar::from_system_style(AzString::from("x"), &ss);
1476        let csd = Titlebar::from_system_style_csd(AzString::from("x"), &ss);
1477
1478        assert_eq!(csd.height, title_only.height);
1479        assert_eq!(csd.font_size, title_only.font_size);
1480        assert_eq!(csd.title_color, title_only.title_color);
1481        assert_eq!(csd.title_color, DEFAULT_TITLE_COLOR_DARK);
1482
1483        // The buttons are DOM children in CSD mode, so no space is reserved.
1484        assert_eq!(csd.padding_left.to_bits(), 0_f32.to_bits());
1485        assert_eq!(csd.padding_right.to_bits(), 0_f32.to_bits());
1486    }
1487
1488    #[test]
1489    fn from_system_style_csd_ignores_the_button_area_and_safe_area_entirely() {
1490        let mut ss = blank_system_style();
1491        ss.metrics.titlebar.button_area_width = OptionPixelValue::Some(PixelValue::px(500.0));
1492        ss.metrics.titlebar.padding_horizontal = OptionPixelValue::Some(PixelValue::px(77.0));
1493        ss.metrics.titlebar.safe_area = SafeAreaInsets {
1494            top: OptionPixelValue::Some(PixelValue::px(1.0)),
1495            bottom: OptionPixelValue::Some(PixelValue::px(2.0)),
1496            left: OptionPixelValue::Some(PixelValue::px(3.0)),
1497            right: OptionPixelValue::Some(PixelValue::px(4.0)),
1498        };
1499
1500        let csd = Titlebar::from_system_style_csd(AzString::from("x"), &ss);
1501        assert_eq!(csd.padding_left, 0.0);
1502        assert_eq!(csd.padding_right, 0.0);
1503    }
1504
1505    // ==================================================================
1506    // Titlebar::build_container_style
1507    // ==================================================================
1508
1509    #[test]
1510    fn build_container_style_emits_the_documented_declarations_in_both_modes() {
1511        let t = tb("x");
1512        for show_buttons in [false, true] {
1513            let style = t.build_container_style(show_buttons);
1514            assert_eq!(
1515                properties(&style),
1516                expected_container(&t, show_buttons),
1517                "container declarations drifted (show_buttons = {show_buttons})",
1518            );
1519            assert!(all_unconditional(&style), "a container declaration became conditional");
1520        }
1521    }
1522
1523    #[test]
1524    fn build_container_style_switches_flex_only_for_the_csd_mode() {
1525        let t = tb("x");
1526
1527        let block = t.build_container_style(false);
1528        let flex = t.build_container_style(true);
1529
1530        assert!(properties(&block).contains(&CssProperty::const_display(LayoutDisplay::Block)));
1531        assert!(properties(&flex).contains(&CssProperty::const_display(LayoutDisplay::Flex)));
1532        // Title-only mode must *not* declare flex layout — the doc comment says it
1533        // deliberately avoids flex-grow complexity.
1534        assert!(
1535            !properties(&block)
1536                .iter()
1537                .any(|p| matches!(p, CssProperty::FlexDirection(_) | CssProperty::AlignItems(_))),
1538            "title-only mode leaked flex declarations",
1539        );
1540        // Everything else is identical.
1541        assert_eq!(height_px(&block), height_px(&flex));
1542        assert_eq!(padding_left_px(&block), padding_left_px(&flex));
1543        assert_eq!(padding_right_px(&block), padding_right_px(&flex));
1544    }
1545
1546    #[test]
1547    fn build_container_style_always_declares_the_grab_cursor_and_disables_selection() {
1548        // Without these a drag selects the title text instead of moving the window.
1549        for show_buttons in [false, true] {
1550            let style = tb("x").build_container_style(show_buttons);
1551            let props = properties(&style);
1552            assert!(props.contains(&CssProperty::const_cursor(StyleCursor::Grab)));
1553            assert!(props.contains(&CssProperty::user_select(StyleUserSelect::None)));
1554        }
1555    }
1556
1557    #[test]
1558    fn build_container_style_truncates_the_height_toward_zero() {
1559        // `height as isize` truncates; a 30.9px titlebar is encoded as 30px.
1560        for (h, expected) in [
1561            (30.0_f32, 30.0_f32),
1562            (30.9, 30.0),
1563            (-30.9, -30.0),
1564            (0.0, 0.0),
1565            (-0.0, 0.0),
1566            (0.5, 0.0),
1567            (-0.5, 0.0),
1568            (0.999, 0.0),
1569        ] {
1570            let mut t = tb("x");
1571            t.set_height(h);
1572            assert_eq!(
1573                height_px(&t.build_container_style(false)),
1574                Some(expected),
1575                "height {h} encoded wrongly",
1576            );
1577        }
1578    }
1579
1580    #[test]
1581    fn build_container_style_encodes_a_nan_height_as_zero_pixels() {
1582        // `NaN as isize` saturates to 0, so the encoding is defined rather than
1583        // propagating NaN into the layout solver.
1584        let mut t = tb("x");
1585        t.set_height(f32::NAN);
1586        assert_eq!(height_px(&t.build_container_style(false)), Some(0.0));
1587        assert_eq!(height_px(&t.build_container_style(true)), Some(0.0));
1588    }
1589
1590    #[test]
1591    fn build_container_style_omits_padding_that_is_not_strictly_positive() {
1592        for pad in [0.0_f32, -0.0, -1.0, -1e30, f32::NAN, f32::NEG_INFINITY] {
1593            let mut t = tb("x");
1594            t.padding_left = pad;
1595            t.padding_right = pad;
1596            let style = t.build_container_style(false);
1597            assert_eq!(
1598                padding_left_px(&style),
1599                None,
1600                "padding-left {pad} must not be declared at all",
1601            );
1602            assert_eq!(padding_right_px(&style), None, "padding-right {pad} was declared");
1603        }
1604    }
1605
1606    #[test]
1607    fn build_container_style_emits_sub_pixel_padding_as_a_zero_px_declaration() {
1608        // The `> 0.0` gate lets 0.4px through, and `as isize` then truncates it to
1609        // 0px: the declaration exists but reserves nothing.
1610        let mut t = tb("x");
1611        t.padding_left = 0.4;
1612        t.padding_right = 0.6;
1613        let style = t.build_container_style(false);
1614        assert_eq!(padding_left_px(&style), Some(0.0));
1615        assert_eq!(padding_right_px(&style), Some(0.0));
1616    }
1617
1618    #[test]
1619    fn build_container_style_keeps_the_two_paddings_independent() {
1620        let mut t = tb("x");
1621        t.padding_left = 12.0;
1622        t.padding_right = 0.0;
1623        let style = t.build_container_style(true);
1624        assert_eq!(padding_left_px(&style), Some(12.0));
1625        assert_eq!(padding_right_px(&style), None);
1626    }
1627
1628    // ==================================================================
1629    // Titlebar::build_title_style
1630    // ==================================================================
1631
1632    #[test]
1633    fn build_title_style_emits_the_documented_declarations_in_both_modes() {
1634        let t = tb("x");
1635        for show_buttons in [false, true] {
1636            let style = t.build_title_style(show_buttons);
1637            assert_eq!(
1638                properties(&style),
1639                expected_title(&t, show_buttons),
1640                "title declarations drifted (show_buttons = {show_buttons})",
1641            );
1642            assert!(all_unconditional(&style), "a title declaration became conditional");
1643        }
1644    }
1645
1646    #[test]
1647    fn build_title_style_only_grows_the_title_in_csd_mode() {
1648        // In the flex container the title must claim the space left by the buttons,
1649        // and `min-width: 0` is what lets it actually shrink below its text width.
1650        let t = tb("x");
1651        let flex = properties(&t.build_title_style(true));
1652        assert!(flex.contains(&CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))));
1653        assert!(flex.contains(&CssProperty::const_min_width(LayoutMinWidth::const_px(0))));
1654
1655        let block = properties(&t.build_title_style(false));
1656        assert!(
1657            !block
1658                .iter()
1659                .any(|p| matches!(p, CssProperty::FlexGrow(_) | CssProperty::MinWidth(_))),
1660            "title-only mode leaked flex-grow / min-width",
1661        );
1662    }
1663
1664    #[test]
1665    fn build_title_style_always_centres_clips_and_never_wraps() {
1666        for show_buttons in [false, true] {
1667            let props = properties(&tb("x").build_title_style(show_buttons));
1668            assert!(props.contains(&CssProperty::const_text_align(StyleTextAlign::Center)));
1669            assert!(props.contains(&CssProperty::WhiteSpace(StyleWhiteSpaceValue::Exact(
1670                StyleWhiteSpace::Nowrap
1671            ))));
1672            assert!(props.contains(&CssProperty::const_overflow_x(LayoutOverflow::Hidden)));
1673        }
1674    }
1675
1676    #[test]
1677    fn build_title_style_forwards_the_resolved_title_colour_verbatim() {
1678        for c in [
1679            ColorU { r: 0, g: 0, b: 0, a: 0 },
1680            ColorU { r: 255, g: 255, b: 255, a: 255 },
1681            ColorU { r: 1, g: 2, b: 3, a: 4 },
1682            DEFAULT_TITLE_COLOR_DARK,
1683        ] {
1684            let mut t = tb("x");
1685            t.title_color = c;
1686            assert_eq!(text_color(&t.build_title_style(false)), Some(c));
1687            assert_eq!(text_color(&t.build_title_style(true)), Some(c));
1688        }
1689    }
1690
1691    #[test]
1692    fn build_title_style_centres_vertically_with_half_the_leftover_height() {
1693        for (h, fs, expected) in [
1694            (30.0_f32, 13.0_f32, Some(8.0_f32)), // (30-13)/2 = 8.5 -> 8px
1695            (32.0, 12.0, Some(10.0)),
1696            (40.0, 20.0, Some(10.0)),
1697            (14.0, 13.0, Some(0.0)), // 0.5 -> declared, but 0px
1698        ] {
1699            let mut t = tb("x");
1700            t.set_height(h);
1701            t.font_size = fs;
1702            assert_eq!(
1703                padding_top_px(&t.build_title_style(false)),
1704                expected,
1705                "h={h} fs={fs} produced the wrong vertical padding",
1706            );
1707        }
1708    }
1709
1710    #[test]
1711    fn build_title_style_omits_the_vertical_padding_when_the_text_does_not_fit() {
1712        // `.max(0.0)` must swallow the negative gap: a negative padding-top would
1713        // push the title above the titlebar.
1714        for (h, fs) in [
1715            (13.0_f32, 13.0_f32),
1716            (10.0, 20.0),
1717            (0.0, 13.0),
1718            (-100.0, 13.0),
1719            (f32::NEG_INFINITY, 13.0),
1720            (f32::NAN, 13.0),
1721            (13.0, f32::NAN),
1722        ] {
1723            let mut t = tb("x");
1724            t.set_height(h);
1725            t.font_size = fs;
1726            assert_eq!(
1727                padding_top_px(&t.build_title_style(false)),
1728                None,
1729                "h={h} fs={fs} declared a vertical padding it should have clamped away",
1730            );
1731        }
1732    }
1733
1734    #[test]
1735    fn build_title_style_encodes_a_nan_font_size_as_zero_pixels() {
1736        let mut t = tb("x");
1737        t.font_size = f32::NAN;
1738        assert_eq!(font_size_px(&t.build_title_style(false)), Some(0.0));
1739    }
1740
1741    #[test]
1742    fn build_title_style_truncates_the_font_size_toward_zero() {
1743        for (fs, expected) in [(13.0_f32, 13.0_f32), (13.9, 13.0), (0.5, 0.0), (-13.9, -13.0)] {
1744            let mut t = tb("x");
1745            t.font_size = fs;
1746            assert_eq!(font_size_px(&t.build_title_style(false)), Some(expected));
1747        }
1748    }
1749
1750    // ==================================================================
1751    // The fixed-point encoding boundary
1752    // ==================================================================
1753
1754    #[cfg(panic = "unwind")]
1755    #[test]
1756    fn heights_outside_the_encodable_range_are_not_saturated() {
1757        use std::{
1758            hint::black_box,
1759            panic::{catch_unwind, AssertUnwindSafe},
1760        };
1761
1762        // LATENT BUG, pinned: `PixelValue::const_px` multiplies by 1000 with a
1763        // plain `*`, so any height/font-size whose `as isize` truncation exceeds
1764        // `isize::MAX / 1000` either panics (overflow checks on: a debug build
1765        // dies) or wraps to a garbage length (checks off) — it never saturates.
1766        // Asserted against a probe of the *current* profile so the test is
1767        // profile-independent; adding saturation flips it loudly.
1768        let profile_traps_overflow = catch_unwind(AssertUnwindSafe(|| {
1769            let big = black_box(isize::MAX);
1770            let _ = black_box(big * black_box(1000_isize));
1771        }))
1772        .is_err();
1773
1774        for bogus in UNENCODABLE_FLOATS {
1775            let mut t = tb("x");
1776            t.set_height(bogus);
1777            let panicked =
1778                catch_unwind(AssertUnwindSafe(|| drop(t.build_container_style(false)))).is_err();
1779            assert_eq!(
1780                panicked, profile_traps_overflow,
1781                "height {bogus}: the fixed-point encoding no longer behaves like a raw multiply",
1782            );
1783
1784            let mut f = tb("x");
1785            f.font_size = bogus;
1786            let panicked =
1787                catch_unwind(AssertUnwindSafe(|| drop(f.build_title_style(false)))).is_err();
1788            assert_eq!(
1789                panicked, profile_traps_overflow,
1790                "font size {bogus}: the fixed-point encoding no longer behaves like a raw multiply",
1791            );
1792        }
1793    }
1794
1795    #[cfg(panic = "unwind")]
1796    #[test]
1797    fn an_unencodable_vertical_gap_reaches_the_padding_encoder_unclamped() {
1798        use std::{
1799            hint::black_box,
1800            panic::{catch_unwind, AssertUnwindSafe},
1801        };
1802
1803        let profile_traps_overflow = catch_unwind(AssertUnwindSafe(|| {
1804            let big = black_box(isize::MAX);
1805            let _ = black_box(big * black_box(1000_isize));
1806        }))
1807        .is_err();
1808
1809        // A *positive* unencodable height also blows up through `padding-top`,
1810        // because `(h - fs) / 2` is still unencodable. The negative ones are
1811        // clamped away by `.max(0.0)` and are therefore safe — asserted here so
1812        // the asymmetry is not mistaken for full coverage.
1813        for bogus in [f32::INFINITY, f32::MAX] {
1814            let mut t = tb("x");
1815            t.set_height(bogus);
1816            let panicked =
1817                catch_unwind(AssertUnwindSafe(|| drop(t.build_title_style(false)))).is_err();
1818            assert_eq!(panicked, profile_traps_overflow, "height {bogus} via padding-top");
1819        }
1820
1821        for safe in [f32::NEG_INFINITY, f32::MIN] {
1822            let mut t = tb("x");
1823            t.set_height(safe);
1824            assert_eq!(
1825                padding_top_px(&t.build_title_style(false)),
1826                None,
1827                "height {safe} must be clamped away by .max(0.0)",
1828            );
1829        }
1830    }
1831
1832    // ==================================================================
1833    // Titlebar::dom (title-only)
1834    // ==================================================================
1835
1836    #[test]
1837    fn dom_builds_the_documented_title_only_tree() {
1838        let dom = tb("caption").dom();
1839
1840        assert_eq!(classes(&dom), vec!["csd-titlebar", "__azul-native-titlebar"]);
1841        assert!(ids(&dom).is_empty(), "the container must not claim an id");
1842        assert_eq!(dom.children.as_ref().len(), 1, "title-only mode has exactly one child");
1843
1844        let title = title_node(&dom);
1845        assert_eq!(classes(title), vec!["csd-title"]);
1846        assert_eq!(title.children.as_ref().len(), 1);
1847        assert_eq!(text_of(&title.children.as_ref()[0]), Some("caption"));
1848        assert!(buttons_node(&dom).is_none(), "title-only mode must render no buttons");
1849    }
1850
1851    #[test]
1852    fn dom_puts_the_container_and_title_styles_on_the_right_nodes() {
1853        let t = tb("caption");
1854        let dom = t.clone().dom();
1855
1856        assert_eq!(inline_props(&dom), expected_container(&t, false));
1857        assert_eq!(inline_props(title_node(&dom)), expected_title(&t, false));
1858        // The text node itself carries no styling of its own.
1859        assert!(inline_props(&title_node(&dom).children.as_ref()[0]).is_empty());
1860    }
1861
1862    #[test]
1863    fn dom_registers_exactly_the_three_drag_callbacks_on_the_title_node() {
1864        let dom = tb("x").dom();
1865
1866        assert!(
1867            callbacks_of(&dom).is_empty(),
1868            "the container must carry no callbacks — the title node owns the drag",
1869        );
1870        assert_eq!(
1871            callbacks_of(title_node(&dom)),
1872            vec![
1873                (
1874                    EventFilter::Hover(HoverEventFilter::DragStart),
1875                    callbacks::titlebar_drag_start as usize,
1876                ),
1877                (EventFilter::Hover(HoverEventFilter::Drag), callbacks::titlebar_drag as usize),
1878                (
1879                    EventFilter::Hover(HoverEventFilter::DoubleClick),
1880                    callbacks::titlebar_double_click as usize,
1881                ),
1882            ],
1883        );
1884    }
1885
1886    #[test]
1887    fn dom_carries_pathological_titles_into_the_text_node_verbatim() {
1888        for title in ADVERSARIAL_TITLES {
1889            let dom = tb(title).dom();
1890            let text = &title_node(&dom).children.as_ref()[0];
1891            assert_eq!(text_of(text), Some(title), "the title was mangled on the way in");
1892            // Even an empty title still gets a text node, so the drag target exists.
1893            assert_eq!(title_node(&dom).children.as_ref().len(), 1);
1894        }
1895    }
1896
1897    #[test]
1898    fn dom_keeps_the_cached_child_count_in_sync() {
1899        // A too-small `estimated_total_children` makes `convert_dom_into_compact_dom`
1900        // under-allocate and panic on an out-of-bounds write.
1901        let dom = tb("x").dom();
1902        assert_eq!(dom.estimated_total_children, count_descendants(&dom));
1903        assert_eq!(dom.estimated_total_children, 2, "title div + text node");
1904    }
1905
1906    #[test]
1907    fn the_dom_conversion_is_exactly_dom() {
1908        for title in ADVERSARIAL_TITLES {
1909            let via_from: Dom = tb(title).into();
1910            assert_eq!(
1911                fingerprint(&via_from),
1912                fingerprint(&tb(title).dom()),
1913                "From<Titlebar> for Dom drifted away from Titlebar::dom",
1914            );
1915        }
1916    }
1917
1918    // ==================================================================
1919    // Titlebar::dom_with_buttons / build_button_container
1920    // ==================================================================
1921
1922    #[test]
1923    fn dom_with_buttons_orders_the_children_by_button_side() {
1924        let buttons = TitlebarButtons::default();
1925
1926        let left = tb("x").dom_with_buttons(&buttons, TitlebarButtonSide::Left);
1927        let left_kids = left.children.as_ref();
1928        assert_eq!(left_kids.len(), 2);
1929        assert!(has_class(&left_kids[0], "csd-buttons"), "macOS puts the buttons first");
1930        assert!(has_class(&left_kids[1], "csd-title"));
1931
1932        let right = tb("x").dom_with_buttons(&buttons, TitlebarButtonSide::Right);
1933        let right_kids = right.children.as_ref();
1934        assert_eq!(right_kids.len(), 2);
1935        assert!(has_class(&right_kids[0], "csd-title"), "Windows/Linux put the title first");
1936        assert!(has_class(&right_kids[1], "csd-buttons"));
1937    }
1938
1939    #[test]
1940    fn dom_with_buttons_emits_one_node_per_enabled_button_in_minimize_maximize_close_order() {
1941        for buttons in all_button_combinations() {
1942            for side in BOTH_SIDES {
1943                let dom = tb("x").dom_with_buttons(&buttons, side);
1944                let container = buttons_node(&dom).expect("the CSD button container is mandatory");
1945
1946                let mut expected: Vec<&str> = Vec::new();
1947                if buttons.has_minimize {
1948                    expected.push("csd-button-minimize");
1949                }
1950                if buttons.has_maximize {
1951                    expected.push("csd-button-maximize");
1952                }
1953                if buttons.has_close {
1954                    expected.push("csd-button-close");
1955                }
1956
1957                let actual: Vec<String> = container
1958                    .children
1959                    .as_ref()
1960                    .iter()
1961                    .flat_map(ids)
1962                    .collect();
1963                assert_eq!(actual, expected, "{buttons:?} on {side:?} produced the wrong buttons");
1964            }
1965        }
1966    }
1967
1968    #[test]
1969    fn has_fullscreen_is_never_rendered() {
1970        // The flag exists in `TitlebarButtons` but the widget has no fullscreen
1971        // button; toggling it must not change a single node.
1972        for &(close, min, max) in &[(true, true, true), (false, false, false), (true, false, true)]
1973        {
1974            let off = TitlebarButtons {
1975                has_close: close,
1976                has_minimize: min,
1977                has_maximize: max,
1978                has_fullscreen: false,
1979            };
1980            let on = TitlebarButtons { has_fullscreen: true, ..off };
1981            assert_eq!(
1982                fingerprint(&build_button_container(&off)),
1983                fingerprint(&build_button_container(&on)),
1984                "has_fullscreen changed the rendered buttons",
1985            );
1986        }
1987    }
1988
1989    #[test]
1990    fn all_buttons_disabled_still_emits_an_empty_button_container() {
1991        let none = TitlebarButtons {
1992            has_close: false,
1993            has_minimize: false,
1994            has_maximize: false,
1995            has_fullscreen: false,
1996        };
1997        let container = build_button_container(&none);
1998
1999        assert_eq!(classes(&container), vec!["csd-buttons"]);
2000        assert!(container.children.as_ref().is_empty());
2001        assert_eq!(container.estimated_total_children, 0);
2002
2003        // ... and the full DOM still has both children in the documented order.
2004        let dom = tb("x").dom_with_buttons(&none, TitlebarButtonSide::Right);
2005        assert_eq!(dom.children.as_ref().len(), 2);
2006        assert!(buttons_node(&dom).is_some());
2007    }
2008
2009    #[test]
2010    fn every_button_carries_one_mousedown_callback_and_the_matching_icon() {
2011        let expected: [(&str, &str, usize); 3] = [
2012            ("csd-button-minimize", "minimize", callbacks::csd_minimize as usize),
2013            ("csd-button-maximize", "maximize", callbacks::csd_maximize as usize),
2014            ("csd-button-close", "close", callbacks::csd_close as usize),
2015        ];
2016
2017        let container = build_button_container(&TitlebarButtons::default());
2018        let kids = container.children.as_ref();
2019        assert_eq!(kids.len(), 3);
2020
2021        for (node, (id, icon, cb)) in kids.iter().zip(expected) {
2022            assert_eq!(ids(node), vec![id]);
2023            assert_eq!(
2024                callbacks_of(node),
2025                vec![(EventFilter::Hover(HoverEventFilter::MouseDown), cb)],
2026                "{id} must carry exactly one MouseDown callback",
2027            );
2028            assert_eq!(node.children.as_ref().len(), 1);
2029            assert_eq!(
2030                icon_of(&node.children.as_ref()[0]),
2031                Some(icon),
2032                "{id} rendered the wrong icon",
2033            );
2034        }
2035    }
2036
2037    #[test]
2038    fn every_button_carries_the_shared_and_the_specific_class() {
2039        let container = build_button_container(&TitlebarButtons::default());
2040        for (node, specific) in container
2041            .children
2042            .as_ref()
2043            .iter()
2044            .zip(["csd-minimize", "csd-maximize", "csd-close"])
2045        {
2046            assert_eq!(
2047                classes(node),
2048                vec!["csd-button".to_string(), specific.to_string()],
2049                "the stylesheet hooks documented on Titlebar are missing",
2050            );
2051        }
2052    }
2053
2054    #[test]
2055    fn dom_with_buttons_keeps_the_cached_child_count_in_sync_for_every_combination() {
2056        for buttons in all_button_combinations() {
2057            for side in BOTH_SIDES {
2058                let dom = tb("x").dom_with_buttons(&buttons, side);
2059                assert_eq!(
2060                    dom.estimated_total_children,
2061                    count_descendants(&dom),
2062                    "{buttons:?} on {side:?} desynced the cached child count",
2063                );
2064
2065                let enabled = usize::from(buttons.has_close)
2066                    + usize::from(buttons.has_minimize)
2067                    + usize::from(buttons.has_maximize);
2068                // title + text + button container + 2 nodes per enabled button
2069                assert_eq!(dom.estimated_total_children, 3 + 2 * enabled);
2070            }
2071        }
2072    }
2073
2074    #[test]
2075    fn dom_with_buttons_uses_the_csd_container_and_title_styles() {
2076        let t = tb("x");
2077        let dom = t.clone().dom_with_buttons(&TitlebarButtons::default(), TitlebarButtonSide::Right);
2078
2079        assert_eq!(inline_props(&dom), expected_container(&t, true));
2080        assert_eq!(inline_props(title_node(&dom)), expected_title(&t, true));
2081        // The button container is styled entirely from the stylesheet.
2082        assert!(inline_props(buttons_node(&dom).unwrap()).is_empty());
2083    }
2084
2085    #[test]
2086    fn the_button_side_changes_only_the_child_order() {
2087        let buttons = TitlebarButtons::default();
2088        let left = tb("x").dom_with_buttons(&buttons, TitlebarButtonSide::Left);
2089        let right = tb("x").dom_with_buttons(&buttons, TitlebarButtonSide::Right);
2090
2091        assert_eq!(inline_props(&left), inline_props(&right));
2092        assert_eq!(classes(&left), classes(&right));
2093        assert_eq!(
2094            fingerprint(title_node(&left)),
2095            fingerprint(title_node(&right)),
2096            "the title node must not depend on the button side",
2097        );
2098        assert_eq!(
2099            fingerprint(buttons_node(&left).unwrap()),
2100            fingerprint(buttons_node(&right).unwrap()),
2101            "the button container must not depend on the button side",
2102        );
2103    }
2104
2105    #[test]
2106    fn dom_with_buttons_carries_pathological_titles_verbatim() {
2107        for title in ADVERSARIAL_TITLES {
2108            let dom = tb(title).dom_with_buttons(&TitlebarButtons::default(), TitlebarButtonSide::Left);
2109            let text = &title_node(&dom).children.as_ref()[0];
2110            assert_eq!(text_of(text), Some(title));
2111        }
2112    }
2113
2114    // ==================================================================
2115    // callbacks::titlebar_drag_start
2116    // ==================================================================
2117
2118    #[test]
2119    fn drag_start_with_an_unknown_position_hands_the_move_to_the_compositor() {
2120        // Wayland hides the window position, so the *only* way to move is
2121        // `xdg_toplevel_move` — the manual loop would be a silent no-op there.
2122        let (update, changes) = with_callback_info(
2123            state_with(WindowFrame::Normal, WindowPosition::Uninitialized),
2124            |info| callbacks::titlebar_drag_start(RefAny::new(()), info),
2125        );
2126
2127        assert_eq!(update, Update::DoNothing);
2128        assert_eq!(interactive_moves(&changes), 1, "the compositor move was not requested");
2129        assert!(state_writes(&changes).is_empty(), "the native path must not write state");
2130    }
2131
2132    #[test]
2133    fn drag_start_with_a_known_position_takes_the_platform_appropriate_path() {
2134        // macOS is documented to take the native path even with a known position
2135        // (performWindowDragWithEvent: is snap- and multi-monitor-aware); X11 and
2136        // Windows fall through to the manual per-event loop. `cfg!` rather than
2137        // `#[cfg]` so both branches keep type-checking on every target.
2138        let (update, changes) = with_callback_info(
2139            state_with(
2140                WindowFrame::Normal,
2141                WindowPosition::Initialized(PhysicalPositionI32::new(100, 200)),
2142            ),
2143            |info| callbacks::titlebar_drag_start(RefAny::new(()), info),
2144        );
2145
2146        assert_eq!(update, Update::DoNothing);
2147        if cfg!(target_os = "macos") {
2148            assert_eq!(interactive_moves(&changes), 1);
2149        } else {
2150            assert_eq!(interactive_moves(&changes), 0, "the manual path must not ask the OS");
2151            assert!(
2152                changes.is_empty(),
2153                "a normal-frame manual drag start must record nothing at all",
2154            );
2155        }
2156    }
2157
2158    #[test]
2159    fn drag_start_restores_a_maximized_window_before_the_manual_move() {
2160        // Dragging a maximized window has to un-maximize first, otherwise the
2161        // manual loop slides the still-maximized frame around the screen.
2162        let before = state_with(
2163            WindowFrame::Maximized,
2164            WindowPosition::Initialized(PhysicalPositionI32::new(0, 0)),
2165        );
2166        let (update, changes) = with_callback_info(before.clone(), |info| {
2167            callbacks::titlebar_drag_start(RefAny::new(()), info)
2168        });
2169
2170        assert_eq!(update, Update::DoNothing);
2171        if cfg!(target_os = "macos") {
2172            assert_eq!(interactive_moves(&changes), 1);
2173            assert!(state_writes(&changes).is_empty());
2174        } else {
2175            let writes = state_writes(&changes);
2176            assert_eq!(writes.len(), 1, "the un-maximize write is missing");
2177            assert_eq!(writes[0].flags.frame, WindowFrame::Normal);
2178
2179            // Nothing else may be touched on the way through.
2180            let mut expected = before;
2181            expected.flags.frame = WindowFrame::Normal;
2182            assert_eq!(writes[0], expected, "drag start changed more than the frame");
2183        }
2184    }
2185
2186    #[test]
2187    fn drag_start_leaves_a_fullscreen_or_minimized_frame_alone() {
2188        for frame in [WindowFrame::Fullscreen, WindowFrame::Minimized, WindowFrame::Normal] {
2189            let (_, changes) = with_callback_info(
2190                state_with(frame, WindowPosition::Initialized(PhysicalPositionI32::new(1, 1))),
2191                |info| callbacks::titlebar_drag_start(RefAny::new(()), info),
2192            );
2193            if !cfg!(target_os = "macos") {
2194                assert!(
2195                    state_writes(&changes).is_empty(),
2196                    "{frame:?} must not be rewritten — only Maximized is restored",
2197                );
2198            }
2199        }
2200    }
2201
2202    // ==================================================================
2203    // callbacks::titlebar_drag
2204    // ==================================================================
2205
2206    #[test]
2207    fn drag_without_an_active_gesture_is_a_no_op() {
2208        // No drag is in flight, so `get_drag_delta_screen_incremental()` is None and
2209        // the if-let must not match — a callback that moved the window anyway would
2210        // teleport it on the first stray Drag event.
2211        for position in [
2212            WindowPosition::Uninitialized,
2213            WindowPosition::Initialized(PhysicalPositionI32::new(-5, 7)),
2214        ] {
2215            let (update, changes) =
2216                with_callback_info(state_with(WindowFrame::Normal, position), |info| {
2217                    callbacks::titlebar_drag(RefAny::new(()), info)
2218                });
2219            assert_eq!(update, Update::DoNothing);
2220            assert!(changes.is_empty(), "{position:?}: a no-delta drag recorded a change");
2221        }
2222    }
2223
2224    #[test]
2225    fn drag_is_idempotent_when_repeated_without_a_gesture() {
2226        for _ in 0..4 {
2227            let (update, changes) = with_callback_info(
2228                state_with(
2229                    WindowFrame::Maximized,
2230                    WindowPosition::Initialized(PhysicalPositionI32::new(i32::MAX, i32::MIN)),
2231                ),
2232                |info| callbacks::titlebar_drag(RefAny::new(()), info),
2233            );
2234            assert_eq!(update, Update::DoNothing);
2235            // Extreme coordinates must not tempt the callback into arithmetic it
2236            // was never asked to do.
2237            assert!(changes.is_empty());
2238        }
2239    }
2240
2241    // ==================================================================
2242    // callbacks::titlebar_double_click / csd_maximize
2243    // ==================================================================
2244
2245    #[test]
2246    fn double_click_toggles_maximized_and_normalises_every_other_frame() {
2247        for frame in ALL_FRAMES {
2248            let before = state_with(frame, WindowPosition::Uninitialized);
2249            let (update, changes) = with_callback_info(before.clone(), |info| {
2250                callbacks::titlebar_double_click(RefAny::new(()), info)
2251            });
2252
2253            assert_eq!(update, Update::DoNothing);
2254            let writes = state_writes(&changes);
2255            assert_eq!(writes.len(), 1, "{frame:?}: exactly one state write expected");
2256
2257            let expected_frame = if frame == WindowFrame::Maximized {
2258                WindowFrame::Normal
2259            } else {
2260                WindowFrame::Maximized
2261            };
2262            assert_eq!(writes[0].flags.frame, expected_frame, "{frame:?} toggled wrongly");
2263
2264            let mut expected = before;
2265            expected.flags.frame = expected_frame;
2266            assert_eq!(writes[0], expected, "{frame:?}: more than the frame changed");
2267        }
2268    }
2269
2270    #[test]
2271    fn double_clicking_twice_returns_to_the_original_frame() {
2272        let (_, changes) = with_callback_info(
2273            state_with(WindowFrame::Normal, WindowPosition::Uninitialized),
2274            |info| callbacks::titlebar_double_click(RefAny::new(()), info),
2275        );
2276        let once = state_writes(&changes).remove(0);
2277        assert_eq!(once.flags.frame, WindowFrame::Maximized);
2278
2279        let (_, changes) = with_callback_info(once, |info| {
2280            callbacks::titlebar_double_click(RefAny::new(()), info)
2281        });
2282        assert_eq!(state_writes(&changes)[0].flags.frame, WindowFrame::Normal);
2283    }
2284
2285    #[test]
2286    fn the_maximize_button_agrees_with_the_double_click_for_every_frame() {
2287        for frame in ALL_FRAMES {
2288            let before = state_with(frame, WindowPosition::Uninitialized);
2289            let (_, via_button) = with_callback_info(before.clone(), |info| {
2290                callbacks::csd_maximize(RefAny::new(()), info)
2291            });
2292            let (_, via_double) = with_callback_info(before, |info| {
2293                callbacks::titlebar_double_click(RefAny::new(()), info)
2294            });
2295            assert_eq!(
2296                state_writes(&via_button),
2297                state_writes(&via_double),
2298                "{frame:?}: the maximize button and the double-click diverged",
2299            );
2300        }
2301    }
2302
2303    // ==================================================================
2304    // callbacks::csd_close / csd_minimize
2305    // ==================================================================
2306
2307    #[test]
2308    fn close_sets_close_requested_and_nothing_else() {
2309        for frame in ALL_FRAMES {
2310            let before = state_with(frame, WindowPosition::Uninitialized);
2311            assert!(!before.flags.close_requested, "fixture must start un-closed");
2312
2313            let (update, changes) =
2314                with_callback_info(before.clone(), |info| callbacks::csd_close(RefAny::new(()), info));
2315
2316            assert_eq!(update, Update::DoNothing);
2317            let writes = state_writes(&changes);
2318            assert_eq!(writes.len(), 1);
2319            assert!(writes[0].flags.close_requested);
2320            assert_eq!(writes[0].flags.frame, frame, "close must not move the frame");
2321
2322            let mut expected = before;
2323            expected.flags.close_requested = true;
2324            assert_eq!(writes[0], expected, "close changed more than close_requested");
2325        }
2326    }
2327
2328    #[test]
2329    fn close_is_idempotent_on_an_already_closing_window() {
2330        let mut before = state_with(WindowFrame::Normal, WindowPosition::Uninitialized);
2331        before.flags.close_requested = true;
2332
2333        let (_, changes) =
2334            with_callback_info(before.clone(), |info| callbacks::csd_close(RefAny::new(()), info));
2335
2336        assert_eq!(state_writes(&changes), vec![before], "a second close must be a re-assert");
2337    }
2338
2339    #[test]
2340    fn minimize_always_minimizes_regardless_of_the_current_frame() {
2341        for frame in ALL_FRAMES {
2342            let before = state_with(frame, WindowPosition::Uninitialized);
2343            let (update, changes) = with_callback_info(before.clone(), |info| {
2344                callbacks::csd_minimize(RefAny::new(()), info)
2345            });
2346
2347            assert_eq!(update, Update::DoNothing);
2348            let writes = state_writes(&changes);
2349            assert_eq!(writes.len(), 1);
2350            assert_eq!(writes[0].flags.frame, WindowFrame::Minimized, "{frame:?} was not minimized");
2351
2352            let mut expected = before;
2353            expected.flags.frame = WindowFrame::Minimized;
2354            assert_eq!(writes[0], expected, "{frame:?}: minimize changed more than the frame");
2355        }
2356    }
2357
2358    #[test]
2359    fn minimize_never_requests_a_close() {
2360        let (_, changes) = with_callback_info(
2361            state_with(WindowFrame::Normal, WindowPosition::Uninitialized),
2362            |info| callbacks::csd_minimize(RefAny::new(()), info),
2363        );
2364        assert!(!state_writes(&changes)[0].flags.close_requested);
2365        assert_eq!(interactive_moves(&changes), 0);
2366    }
2367}
2368