Skip to main content

azul_core/
callbacks.rs

1//! Callback types for the Azul UI framework.
2//!
3//! This module defines the callback infrastructure used by the event system,
4//! layout engine, and virtual view rendering. Key design patterns:
5//!
6//! - **Core vs Layout callback split**: `CoreCallbackType` and
7//!   `CoreRenderImageCallbackType` store function pointers as `usize` to avoid
8//!   circular dependencies between `azul-core` and `azul-layout`. The actual
9//!   function pointer types are defined in `azul-layout` and transmuted at
10//!   invocation time.
11//!
12//! - **FFI callable pattern**: Callback structs carry an optional
13//!   `ctx: OptionRefAny` field that holds a foreign callable (e.g. a Python
14//!   function object). The `extern "C"` trampoline stored in `cb` extracts
15//!   both the user data and the foreign callable from `RefAny` and dispatches
16//!   the call. Native Rust code sets `ctx` to `None`.
17//!
18//! - **Info structs**: `LayoutCallbackInfo`, `VirtualViewCallbackInfo`, and
19//!   the layout-side `CallbackInfo` provide read-only access to framework
20//!   resources (fonts, images, GL context, window size) during callback
21//!   invocation.
22
23#[cfg(not(feature = "std"))]
24use alloc::string::ToString;
25use alloc::{alloc::Layout, boxed::Box, collections::BTreeMap, sync::Arc, vec::Vec};
26use core::{
27    ffi::c_void,
28    fmt,
29    sync::atomic::{AtomicUsize, Ordering as AtomicOrdering},
30};
31#[cfg(feature = "std")]
32use std::hash::Hash;
33
34use azul_css::{
35    css::{CssPath, CssPropertyValue},
36    props::{
37        basic::{
38            AnimationInterpolationFunction, FontRef, InterpolateResolver, LayoutRect, LayoutSize,
39        },
40        property::{CssProperty, CssPropertyType},
41    },
42    system::SystemStyle,
43    AzString,
44};
45use rust_fontconfig::{FcFontCache, OwnedFontSource};
46
47use crate::{
48    dom::{Dom, DomId, DomNodeId, EventFilter, OptionDom},
49    geom::{LogicalPosition, LogicalRect, LogicalSize, OptionLogicalPosition, PhysicalSize},
50    gl::OptionGlContextPtr,
51    hit_test::OverflowingScrollNode,
52    id::{NodeDataContainer, NodeDataContainerRef, NodeDataContainerRefMut, NodeId},
53    prop_cache::CssPropertyCache,
54    refany::{OptionRefAny, RefAny},
55    resources::{
56        DpiScaleFactor, FontInstanceKey, IdNamespace, ImageCache, ImageMask, ImageRef,
57        RendererResources,
58    },
59    styled_dom::{
60        NodeHierarchyItemId, NodeHierarchyItemVec, StyledNode,
61        StyledNodeVec,
62    },
63    task::{
64        Duration as AzDuration, GetSystemTimeCallback, Instant as AzInstant, Instant,
65        TerminateTimer, ThreadId, ThreadReceiver, ThreadSendMsg, TimerId,
66    },
67    window::{
68        AzStringPair, KeyboardState, MouseState, OptionChar, RawWindowHandle, UpdateFocusWarning,
69        WindowFlags, WindowSize, WindowTheme,
70    },
71    FastBTreeSet, OrderedMap,
72};
73
74/// Specifies if the screen should be updated after the callback function has returned
75#[repr(C)]
76#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
77pub enum Update {
78    /// The screen does not need to redraw after the callback has been called
79    DoNothing,
80    /// After the callback is called, the screen needs to redraw (`layout()` function being called
81    /// again)
82    RefreshDom,
83    /// The layout has to be re-calculated for all windows
84    RefreshDomAllWindows,
85}
86
87impl Update {
88    pub fn max_self(&mut self, other: Self) {
89        if (*self == Self::DoNothing && other != Self::DoNothing)
90            || (*self == Self::RefreshDom && other == Self::RefreshDomAllWindows)
91        {
92            *self = other;
93        }
94    }
95}
96
97// -- layout callback
98
99/// Callback function pointer (has to be a function pointer in
100/// order to be compatible with C APIs later on).
101///
102/// IMPORTANT: The callback needs to deallocate the `RefAnyPtr` and `LayoutCallbackInfoPtr`,
103/// otherwise that memory is leaked. If you use the official auto-generated
104/// bindings, this is already done for you.
105///
106/// NOTE: The original callback was `fn(&self, LayoutCallbackInfo) -> Dom`
107/// which then evolved to `fn(&RefAny, LayoutCallbackInfo) -> Dom`.
108/// The indirection is necessary because of the memory management
109/// around the C API
110///
111/// The memory management across the callback boundary is handled by
112/// the caller (see `LayoutCallback` and `LayoutCallbackInfo`).
113pub type LayoutCallbackType = extern "C" fn(RefAny, LayoutCallbackInfo) -> Dom;
114
115extern "C" fn default_layout_callback(_: RefAny, _: LayoutCallbackInfo) -> Dom {
116    Dom::create_body()
117}
118
119/// Wrapper around the layout callback
120///
121/// For FFI languages (Python, Java, etc.), the `RefAny` contains both:
122/// - The user's application data
123/// - The callback function object from the foreign language
124///
125/// The trampoline function (stored in `cb`) knows how to extract both
126/// from the `RefAny` and invoke the foreign callback with the user data.
127#[repr(C)]
128pub struct LayoutCallback {
129    pub cb: LayoutCallbackType,
130    /// For FFI: stores the foreign callable (e.g., `PyFunction`)
131    /// Native Rust code sets this to None
132    pub ctx: OptionRefAny,
133}
134
135impl_callback!(LayoutCallback, LayoutCallbackType);
136
137impl LayoutCallback {
138    pub fn create<I: Into<Self>>(cb: I) -> Self {
139        cb.into()
140    }
141}
142
143// Host-invoker plumbing for managed-FFI bindings (Lua, Ruby, Perl, …):
144// expands to a static `az_layout_callback_thunk` (the `cb` we hand to the
145// framework when the host calls `LayoutCallback::create_from_host_handle`),
146// an `AzLayoutCallback_createFromHostHandle` C-ABI export, plus the
147// `AzApp_setLayoutCallbackInvoker` setter the host calls once at module
148// load. See `crate::host_invoker` for the design.
149crate::impl_managed_callback! {
150    wrapper:        LayoutCallback,
151    info_ty:        LayoutCallbackInfo,
152    return_ty:      Dom,
153    default_ret:    Dom::create_body(),
154    invoker_static: LAYOUT_CALLBACK_INVOKER,
155    invoker_ty:     AzLayoutCallbackInvoker,
156    thunk_fn:       az_layout_callback_thunk,
157    setter_fn:      AzApp_setLayoutCallbackInvoker,
158    from_handle_fn: AzLayoutCallback_createFromHostHandle,
159}
160
161impl Default for LayoutCallback {
162    fn default() -> Self {
163        Self {
164            cb: default_layout_callback,
165            ctx: OptionRefAny::None,
166        }
167    }
168}
169
170// -- virtualized view callback
171
172pub type VirtualViewCallbackType = extern "C" fn(RefAny, VirtualViewCallbackInfo) -> VirtualViewReturn;
173
174/// Callback that, given a rectangle area on the screen, returns the DOM
175/// appropriate for that bounds (useful for infinite lists)
176#[repr(C)]
177pub struct VirtualViewCallback {
178    pub cb: VirtualViewCallbackType,
179    /// For FFI: stores the foreign callable (e.g., `PyFunction`)
180    /// Native Rust code sets this to None
181    pub ctx: OptionRefAny,
182}
183impl_callback!(VirtualViewCallback, VirtualViewCallbackType);
184
185// Host-invoker plumbing for VirtualViewCallback. See `crate::host_invoker`.
186crate::impl_managed_callback! {
187    wrapper:        VirtualViewCallback,
188    info_ty:        VirtualViewCallbackInfo,
189    return_ty:      VirtualViewReturn,
190    default_ret:    VirtualViewReturn::default(),
191    invoker_static: VIRTUAL_VIEW_CALLBACK_INVOKER,
192    invoker_ty:     AzVirtualViewCallbackInvoker,
193    thunk_fn:       az_virtual_view_callback_thunk,
194    setter_fn:      AzApp_setVirtualViewCallbackInvoker,
195    from_handle_fn: AzVirtualViewCallback_createFromHostHandle,
196}
197
198impl VirtualViewCallback {
199    pub fn create(cb: VirtualViewCallbackType) -> Self {
200        Self {
201            cb,
202            ctx: OptionRefAny::None,
203        }
204    }
205}
206
207/// Reason why a `VirtualView` callback is being invoked.
208///
209/// This helps the callback optimize its behavior based on why it's being called.
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211#[repr(C, u8)]
212pub enum VirtualViewCallbackReason {
213    /// Initial render - first time the `VirtualView` appears
214    InitialRender,
215    /// Parent DOM was recreated (cache invalidated)
216    DomRecreated,
217    /// Window/VirtualView bounds expanded beyond current `scroll_size`
218    BoundsExpanded,
219    /// Scroll position is near an edge (within `EDGE_THRESHOLD`, currently 200px)
220    EdgeScrolled(EdgeType),
221    /// Scroll position extends beyond current `scroll_size`
222    ScrollBeyondContent,
223}
224
225/// Which edge triggered a scroll-based re-invocation
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227#[repr(C)]
228pub enum EdgeType {
229    Top,
230    Bottom,
231    Left,
232    Right,
233}
234
235#[derive(Debug)]
236#[repr(C)]
237pub struct VirtualViewCallbackInfo {
238    pub reason: VirtualViewCallbackReason,
239    pub system_fonts: *const FcFontCache,
240    pub image_cache: *const ImageCache,
241    pub window_theme: WindowTheme,
242    pub bounds: HidpiAdjustedBounds,
243    pub scroll_size: LogicalSize,
244    pub scroll_offset: LogicalPosition,
245    pub virtual_scroll_size: LogicalSize,
246    pub virtual_scroll_offset: LogicalPosition,
247    /// Pointer to the callable (`OptionRefAny`) for FFI language bindings (Python, etc.)
248    /// Set by the caller before invoking the callback. Native Rust callbacks have this as null.
249    callable_ptr: *const OptionRefAny,
250    /// Headless DOM measurement hook (see [`Self::measure_dom`]): a
251    /// layout-crate trampoline (a [`MeasureDomFn`] stored as an opaque
252    /// pointer, null = no hook) + its `LayoutWindow` context, injected at
253    /// invoke time. Null on paths that cannot measure (then `measure_dom`
254    /// returns zero).
255    measure_dom_fn: *const c_void,
256    measure_dom_ctx: *mut c_void,
257    /// Extension for future ABI stability (mutable data)
258    _abi_mut: *mut c_void,
259}
260
261/// Trampoline signature for [`VirtualViewCallbackInfo::measure_dom`]:
262/// `(layout_window_ctx, dom, available) -> content extent`. The `Dom` is
263/// passed by pointer and CONSUMED (moved out) by the trampoline.
264pub type MeasureDomFn = extern "C" fn(*mut c_void, *mut Dom, LogicalSize) -> LogicalSize;
265
266impl Clone for VirtualViewCallbackInfo {
267    #[allow(clippy::used_underscore_binding)] // intentional `_`-prefix (FFI/api.json pub field, or cfg-gated binding); access is deliberate
268    fn clone(&self) -> Self {
269        Self {
270            reason: self.reason,
271            system_fonts: self.system_fonts,
272            image_cache: self.image_cache,
273            window_theme: self.window_theme,
274            bounds: self.bounds,
275            scroll_size: self.scroll_size,
276            scroll_offset: self.scroll_offset,
277            virtual_scroll_size: self.virtual_scroll_size,
278            virtual_scroll_offset: self.virtual_scroll_offset,
279            callable_ptr: self.callable_ptr,
280            measure_dom_fn: self.measure_dom_fn,
281            measure_dom_ctx: self.measure_dom_ctx,
282            _abi_mut: self._abi_mut,
283        }
284    }
285}
286
287impl VirtualViewCallbackInfo {
288    #[must_use] pub const fn new<'a>(
289        reason: VirtualViewCallbackReason,
290        system_fonts: &'a FcFontCache,
291        image_cache: &'a ImageCache,
292        window_theme: WindowTheme,
293        bounds: HidpiAdjustedBounds,
294        scroll_size: LogicalSize,
295        scroll_offset: LogicalPosition,
296        virtual_scroll_size: LogicalSize,
297        virtual_scroll_offset: LogicalPosition,
298    ) -> Self {
299        Self {
300            reason,
301            system_fonts: core::ptr::from_ref::<FcFontCache>(system_fonts),
302            image_cache: core::ptr::from_ref::<ImageCache>(image_cache),
303            window_theme,
304            bounds,
305            scroll_size,
306            scroll_offset,
307            virtual_scroll_size,
308            virtual_scroll_offset,
309            callable_ptr: core::ptr::null(),
310            measure_dom_fn: core::ptr::null(),
311            measure_dom_ctx: core::ptr::null_mut(),
312            _abi_mut: core::ptr::null_mut(),
313        }
314    }
315
316    /// Set the callable pointer for FFI language bindings
317    pub const fn set_callable_ptr(&mut self, callable: &OptionRefAny) {
318        self.callable_ptr = core::ptr::from_ref::<OptionRefAny>(callable);
319    }
320
321    /// Inject the headless-measure trampoline (called by the layout crate
322    /// right before the user callback is invoked).
323    pub fn set_measure_dom_fn(&mut self, f: MeasureDomFn, ctx: *mut c_void) {
324        self.measure_dom_fn = f as *const c_void;
325        self.measure_dom_ctx = ctx;
326    }
327
328    /// Measure a DOM headlessly: style + lay it out against `available`
329    /// constraints using the host window's fonts and system style, without
330    /// touching the live layout. Returns the union of all node bounds.
331    ///
332    /// Use a very tall `available.height` (e.g. `1_000_000.0`) to obtain a
333    /// DOM's natural height at a fixed width - the building block for
334    /// virtual-scroll sizing: measure one (or a few) item template(s), then
335    /// `virtual_scroll_size.height = item_height * item_count` and render
336    /// only the visible window of items. Each call is a full cold layout
337    /// pass, so cache measured sizes per item template.
338    ///
339    /// Returns `LogicalSize::zero()` when no measure hook was injected.
340    #[must_use] pub fn measure_dom(&self, dom: Dom, available: LogicalSize) -> LogicalSize {
341        if self.measure_dom_fn.is_null() {
342            return LogicalSize::zero();
343        }
344        // SAFETY: measure_dom_fn is only ever set via set_measure_dom_fn,
345        // which stores a valid MeasureDomFn.
346        let f: MeasureDomFn = unsafe { core::mem::transmute(self.measure_dom_fn) };
347        let mut dom = core::mem::ManuallyDrop::new(dom);
348        f(self.measure_dom_ctx, core::ptr::from_mut::<Dom>(&mut dom), available)
349    }
350
351    /// Get the callable for FFI language bindings (Python, etc.)
352    #[must_use] pub fn get_ctx(&self) -> OptionRefAny {
353        if self.callable_ptr.is_null() {
354            OptionRefAny::None
355        } else {
356            unsafe { (*self.callable_ptr).clone() }
357        }
358    }
359
360    #[must_use] pub const fn get_bounds(&self) -> HidpiAdjustedBounds {
361        self.bounds
362    }
363
364    const fn internal_get_system_fonts(&self) -> &FcFontCache {
365        unsafe { &*self.system_fonts }
366    }
367    const fn internal_get_image_cache(&self) -> &ImageCache {
368        unsafe { &*self.image_cache }
369    }
370}
371
372/// Return value for a `VirtualView` rendering callback.
373///
374/// Contains two size/offset pairs for lazy loading and virtualization:
375///
376/// - `scroll_size` / `scroll_offset`: Size and position of actually rendered content
377/// - `virtual_scroll_size` / `virtual_scroll_offset`: Size for scrollbar representation
378///
379/// The callback is re-invoked on: initial render, parent DOM recreation, window expansion
380/// beyond `scroll_size`, or scrolling near content edges (`EDGE_THRESHOLD`, currently 200px).
381///
382/// Return `OptionDom::None` to keep the current DOM and only update scroll bounds.
383#[derive(Debug, Clone, PartialEq, Eq)]
384#[repr(C)]
385pub struct VirtualViewReturn {
386    /// The DOM with actual rendered content, or None to keep current DOM.
387    ///
388    /// - `OptionDom::Some(dom)` - Replace current content with this new DOM
389    /// - `OptionDom::None` - Keep using the previous DOM, only update scroll bounds
390    ///
391    /// Returning `None` is an optimization when the callback determines that the
392    /// current content is sufficient (e.g., already rendered ahead of scroll position).
393    pub dom: OptionDom,
394
395    /// Size of the actual rendered content rectangle.
396    ///
397    /// This is the size of the content in the `dom` field (if Some). It may be smaller than
398    /// `virtual_scroll_size` if only a subset of content is rendered (virtualization).
399    ///
400    /// **Example**: For a table showing rows 10-30, this might be 600px tall
401    /// (20 rows x 30px each).
402    pub scroll_size: LogicalSize,
403
404    /// Offset of the actual rendered content within the virtual coordinate space.
405    ///
406    /// This positions the rendered content within the larger virtual space. For
407    /// virtualized content, this will be non-zero to indicate where the rendered
408    /// "window" starts.
409    ///
410    /// **Example**: For a table showing rows 10-30, this might be y=300
411    /// (row 10 starts 300px from the top).
412    pub scroll_offset: LogicalPosition,
413
414    /// Size of the virtual content rectangle (for scrollbar sizing).
415    ///
416    /// This is the size the scrollbar will represent. It can be much larger than
417    /// `scroll_size` to enable lazy loading and virtualization.
418    ///
419    /// **Example**: For a 1000-row table, this might be 30,000px tall
420    /// (1000 rows x 30px each), even though only 20 rows are actually rendered.
421    pub virtual_scroll_size: LogicalSize,
422
423    /// Offset of the virtual content (usually zero).
424    ///
425    /// This is typically `(0, 0)` since the virtual space usually starts at the origin.
426    /// Advanced use cases might use this for complex virtualization scenarios.
427    pub virtual_scroll_offset: LogicalPosition,
428}
429
430impl Default for VirtualViewReturn {
431    fn default() -> Self {
432        Self {
433            dom: OptionDom::None,
434            scroll_size: LogicalSize::zero(),
435            scroll_offset: LogicalPosition::zero(),
436            virtual_scroll_size: LogicalSize::zero(),
437            virtual_scroll_offset: LogicalPosition::zero(),
438        }
439    }
440}
441
442impl VirtualViewReturn {
443    /// Creates a new `VirtualViewReturn` with updated DOM content.
444    ///
445    /// Use this when the callback has rendered new content to display.
446    ///
447    /// # Arguments
448    /// - `dom` - The new DOM to render
449    /// - `scroll_size` - Size of the actual rendered content
450    /// - `scroll_offset` - Position of rendered content in virtual space
451    /// - `virtual_scroll_size` - Size for scrollbar representation
452    /// - `virtual_scroll_offset` - Usually `LogicalPosition::zero()`
453    #[must_use] pub const fn with_dom(
454        dom: Dom,
455        scroll_size: LogicalSize,
456        scroll_offset: LogicalPosition,
457        virtual_scroll_size: LogicalSize,
458        virtual_scroll_offset: LogicalPosition,
459    ) -> Self {
460        Self {
461            dom: OptionDom::Some(dom),
462            scroll_size,
463            scroll_offset,
464            virtual_scroll_size,
465            virtual_scroll_offset,
466        }
467    }
468
469    /// Creates a return value that keeps the current DOM unchanged.
470    ///
471    /// Use this when the callback determines that the existing content
472    /// is sufficient (e.g., already rendered ahead of scroll position).
473    /// This is an optimization to avoid rebuilding the DOM unnecessarily.
474    ///
475    /// # Arguments
476    /// - `scroll_size` - Size of the current rendered content
477    /// - `scroll_offset` - Position of current content in virtual space
478    /// - `virtual_scroll_size` - Size for scrollbar representation
479    /// - `virtual_scroll_offset` - Usually `LogicalPosition::zero()`
480    #[must_use] pub const fn keep_current(
481        scroll_size: LogicalSize,
482        scroll_offset: LogicalPosition,
483        virtual_scroll_size: LogicalSize,
484        virtual_scroll_offset: LogicalPosition,
485    ) -> Self {
486        Self {
487            dom: OptionDom::None,
488            scroll_size,
489            scroll_offset,
490            virtual_scroll_size,
491            virtual_scroll_offset,
492        }
493    }
494
495}
496
497// --  thread callback
498
499// -- timer callback
500
501#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
502#[repr(C)]
503pub struct TimerCallbackReturn {
504    pub should_update: Update,
505    pub should_terminate: TerminateTimer,
506}
507
508impl TimerCallbackReturn {
509    /// Creates a new `TimerCallbackReturn` with the given update and terminate flags.
510    #[must_use] pub const fn create(should_update: Update, should_terminate: TerminateTimer) -> Self {
511        Self {
512            should_update,
513            should_terminate,
514        }
515    }
516
517    /// Timer continues running, no DOM update needed.
518    #[must_use] pub const fn continue_unchanged() -> Self {
519        Self {
520            should_update: Update::DoNothing,
521            should_terminate: TerminateTimer::Continue,
522        }
523    }
524
525    /// Timer continues running and DOM should be refreshed.
526    #[must_use] pub const fn continue_and_refresh_dom() -> Self {
527        Self {
528            should_update: Update::RefreshDom,
529            should_terminate: TerminateTimer::Continue,
530        }
531    }
532
533    /// Timer should stop, no DOM update needed.
534    #[must_use] pub const fn terminate_unchanged() -> Self {
535        Self {
536            should_update: Update::DoNothing,
537            should_terminate: TerminateTimer::Terminate,
538        }
539    }
540
541    /// Timer should stop and DOM should be refreshed.
542    #[must_use] pub const fn terminate_and_refresh_dom() -> Self {
543        Self {
544            should_update: Update::RefreshDom,
545            should_terminate: TerminateTimer::Terminate,
546        }
547    }
548}
549
550impl Default for TimerCallbackReturn {
551    fn default() -> Self {
552        Self::continue_unchanged()
553    }
554}
555
556/// Gives the `layout()` function access to the `RendererResources` and the `Window`
557/// (for querying images and fonts, as well as width / height)
558///
559#[derive(Debug)]
560#[repr(C)]
561/// Reference data container for `LayoutCallbackInfo` (all read-only fields)
562///
563/// This struct consolidates all readonly references that layout callbacks need to query state.
564/// By grouping these into a single struct, we reduce the number of parameters to
565/// `LayoutCallbackInfo::new()` from 6 to 2, making the API more maintainable and easier to extend.
566///
567/// This is pure syntax sugar - the struct lives on the stack in the caller and is passed by
568/// reference.
569pub struct LayoutCallbackInfoRefData<'a> {
570    /// Allows the `layout()` function to reference image IDs
571    pub image_cache: &'a ImageCache,
572    /// OpenGL context so that the `layout()` function can render textures
573    pub gl_context: &'a OptionGlContextPtr,
574    /// Reference to the system font cache
575    pub system_fonts: &'a FcFontCache,
576    /// Platform-specific system style (colors, spacing, etc.)
577    /// Used for CSD rendering and menu windows.
578    pub system_style: Arc<SystemStyle>,
579    /// Active route match (if routing is configured).
580    /// Contains the matched pattern and extracted parameters.
581    pub active_route: Option<&'a crate::resources::RouteMatch>,
582}
583
584/// What triggered the current `layout()` invocation.
585///
586/// The framework re-invokes the layout callback for any change that may
587/// produce a structurally different DOM (resize across a CSS breakpoint,
588/// theme toggle, route switch, callback returning `Update::RefreshDom`).
589/// `LayoutCallbackInfo::relayout_reason()` exposes which trigger this
590/// particular call corresponds to so the callback can branch - for
591/// example, skip expensive analytics on `Resize` calls.
592#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
593#[repr(C)]
594#[derive(Default)]
595pub enum RelayoutReason {
596    /// First layout call for this window.
597    #[default]
598    Initial,
599    /// A user callback returned `Update::RefreshDom`.
600    RefreshDom,
601    /// Window size changed across a CSS breakpoint or DPI scale change.
602    /// The callback can branch on `info.window_width_*` to emit a
603    /// different tree (e.g. hamburger menu vs sidebar).
604    Resize,
605    /// System theme changed (light/dark).
606    ThemeChange,
607    /// `CallbackInfo::switch_route` or `set_route_param` produced a new
608    /// route match. The callback should branch on
609    /// `info.get_active_route()`.
610    RouteChange,
611    /// Catch-all for relayouts that don't fit one of the above categories.
612    Other,
613}
614
615
616#[repr(C)]
617pub struct LayoutCallbackInfo {
618    /// Single reference to all readonly reference data
619    /// This consolidates 4 individual parameters into 1, improving API ergonomics
620    ref_data: *const LayoutCallbackInfoRefData<'static>,
621    /// Window size (so that apps can return a different UI depending on
622    /// the window size - mobile / desktop view). Should be later removed
623    /// in favor of "resize" handlers and @media queries.
624    pub window_size: WindowSize,
625    /// Registers whether the UI is dependent on the window theme
626    pub theme: WindowTheme,
627    /// What triggered this `layout()` call. Read via `relayout_reason()`.
628    pub relayout_reason: RelayoutReason,
629    /// Pointer to the callable (`OptionRefAny`) for FFI language bindings (Python, etc.)
630    /// Set by the caller before invoking the callback. Native Rust callbacks have this as null.
631    callable_ptr: *const OptionRefAny,
632    /// Extension for future ABI stability (mutable data)
633    _abi_mut: *mut c_void,
634}
635
636impl Clone for LayoutCallbackInfo {
637    #[allow(clippy::used_underscore_binding)] // intentional `_`-prefix (FFI/api.json pub field, or cfg-gated binding); access is deliberate
638    fn clone(&self) -> Self {
639        Self {
640            ref_data: self.ref_data,
641            window_size: self.window_size,
642            theme: self.theme,
643            relayout_reason: self.relayout_reason,
644            callable_ptr: self.callable_ptr,
645            _abi_mut: self._abi_mut,
646        }
647    }
648}
649
650impl core::fmt::Debug for LayoutCallbackInfo {
651    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
652        f.debug_struct("LayoutCallbackInfo")
653            .field("window_size", &self.window_size)
654            .field("theme", &self.theme)
655            .field("relayout_reason", &self.relayout_reason)
656            .finish_non_exhaustive()
657    }
658}
659
660impl LayoutCallbackInfo {
661    #[must_use] pub const fn new<'a>(
662        ref_data: &'a LayoutCallbackInfoRefData<'a>,
663        window_size: WindowSize,
664        theme: WindowTheme,
665    ) -> Self {
666        Self::new_with_reason(ref_data, window_size, theme, RelayoutReason::Initial)
667    }
668
669    // the `as *const ...<'static>` is a deliberate 'a -> 'static lifetime launder
670    // on the raw pointer (see SAFETY note below), not a redundant cast.
671    #[allow(clippy::unnecessary_cast)]
672    #[must_use] pub const fn new_with_reason<'a>(
673        ref_data: &'a LayoutCallbackInfoRefData<'a>,
674        window_size: WindowSize,
675        theme: WindowTheme,
676        relayout_reason: RelayoutReason,
677    ) -> Self {
678        Self {
679            // SAFETY: We cast away the lifetime 'a to 'static because LayoutCallbackInfo
680            // only lives for the duration of the callback, which is shorter than 'a
681            ref_data: core::ptr::from_ref::<LayoutCallbackInfoRefData<'a>>(ref_data)
682                as *const LayoutCallbackInfoRefData<'static>,
683            window_size,
684            theme,
685            relayout_reason,
686            callable_ptr: core::ptr::null(),
687            _abi_mut: core::ptr::null_mut(),
688        }
689    }
690
691    /// Returns what triggered the current `layout()` invocation.
692    #[must_use] pub const fn relayout_reason(&self) -> RelayoutReason {
693        self.relayout_reason
694    }
695
696    /// Set the callable pointer for FFI language bindings
697    pub const fn set_callable_ptr(&mut self, callable: &OptionRefAny) {
698        self.callable_ptr = core::ptr::from_ref::<OptionRefAny>(callable);
699    }
700
701    /// Get the callable for FFI language bindings (Python, etc.)
702    #[must_use] pub fn get_ctx(&self) -> OptionRefAny {
703        if self.callable_ptr.is_null() {
704            OptionRefAny::None
705        } else {
706            unsafe { (*self.callable_ptr).clone() }
707        }
708    }
709
710    /// Get a clone of the system style Arc
711    #[must_use] pub fn get_system_style(&self) -> Arc<SystemStyle> {
712        unsafe { (*self.ref_data).system_style.clone() }
713    }
714
715    const fn internal_get_image_cache(&self) -> &ImageCache {
716        unsafe { (*self.ref_data).image_cache }
717    }
718    const fn internal_get_system_fonts(&self) -> &FcFontCache {
719        unsafe { (*self.ref_data).system_fonts }
720    }
721    const fn internal_get_gl_context(&self) -> &OptionGlContextPtr {
722        unsafe { (*self.ref_data).gl_context }
723    }
724
725    #[must_use] pub fn get_gl_context(&self) -> OptionGlContextPtr {
726        self.internal_get_gl_context().clone()
727    }
728
729    #[must_use] pub fn get_system_fonts(&self) -> Vec<AzStringPair> {
730        let fc_cache = self.internal_get_system_fonts();
731
732        fc_cache
733            .list()
734            .into_iter()
735            .filter_map(|(pattern, font_id)| {
736                let source = fc_cache.get_font_by_id(&font_id)?;
737                match source {
738                    OwnedFontSource::Memory(_) => None,
739                    OwnedFontSource::Disk(d) => Some((pattern.name.as_ref()?.clone(), d.path)),
740                }
741            })
742            .map(|(k, v)| AzStringPair {
743                key: k.into(),
744                value: v.into(),
745            })
746            .collect()
747    }
748
749    #[must_use] pub fn get_image(&self, image_id: &AzString) -> Option<ImageRef> {
750        self.internal_get_image_cache()
751            .get_css_image_id(image_id)
752            .cloned()
753    }
754
755    /// Get the active route match (pattern + extracted parameters).
756    ///
757    /// Returns `None` if no routes are configured or no route is active.
758    #[must_use] pub const fn get_active_route(&self) -> Option<&crate::resources::RouteMatch> {
759        unsafe { (*self.ref_data).active_route }
760    }
761
762    /// Get a route parameter by key (e.g. `get_route_param("id")` for `/user/:id`).
763    ///
764    /// Returns `None` if no route is active or the parameter doesn't exist.
765    #[must_use] pub fn get_route_param(&self, key: &str) -> Option<&AzString> {
766        self.get_active_route()?.get_param(key)
767    }
768
769    // Responsive layout helper methods
770    /// Returns true if the window width is less than the given pixel value
771    #[must_use] pub fn window_width_less_than(&self, px: f32) -> bool {
772        self.window_size.dimensions.width < px
773    }
774
775    /// Returns true if the window width is greater than the given pixel value
776    #[must_use] pub fn window_width_greater_than(&self, px: f32) -> bool {
777        self.window_size.dimensions.width > px
778    }
779
780    /// Returns true if the window width is between min and max (inclusive)
781    #[must_use] pub fn window_width_between(&self, min_px: f32, max_px: f32) -> bool {
782        let width = self.window_size.dimensions.width;
783        width >= min_px && width <= max_px
784    }
785
786    /// Returns true if the window height is less than the given pixel value
787    #[must_use] pub fn window_height_less_than(&self, px: f32) -> bool {
788        self.window_size.dimensions.height < px
789    }
790
791    /// Returns true if the window height is greater than the given pixel value
792    #[must_use] pub fn window_height_greater_than(&self, px: f32) -> bool {
793        self.window_size.dimensions.height > px
794    }
795
796    /// Returns true if the window height is between min and max (inclusive)
797    #[must_use] pub fn window_height_between(&self, min_px: f32, max_px: f32) -> bool {
798        let height = self.window_size.dimensions.height;
799        height >= min_px && height <= max_px
800    }
801
802    /// Returns the current window width in pixels
803    #[must_use] pub const fn get_window_width(&self) -> f32 {
804        self.window_size.dimensions.width
805    }
806
807    /// Returns the current window height in pixels
808    #[must_use] pub const fn get_window_height(&self) -> f32 {
809        self.window_size.dimensions.height
810    }
811
812    /// Returns the current window DPI scale factor (1.0 = 96 DPI, 2.0 = 192 DPI)
813    #[allow(clippy::cast_precision_loss)] // bounded DPI/dimension/number conversion
814    #[must_use] pub fn get_dpi_factor(&self) -> f32 {
815        self.window_size.dpi as f32 / 96.0
816    }
817}
818
819/// Information about the bounds of a laid-out div rectangle.
820///
821/// Necessary when invoking `VirtualViewCallbacks` and `RenderImageCallbacks`, so
822/// that they can change what their content is based on their size.
823#[derive(Debug, Copy, Clone)]
824#[repr(C)]
825pub struct HidpiAdjustedBounds {
826    pub logical_size: LogicalSize,
827    pub hidpi_factor: DpiScaleFactor,
828}
829
830impl HidpiAdjustedBounds {
831    #[inline]
832    #[allow(clippy::cast_precision_loss)] // bounded DPI/dimension/number conversion
833    #[must_use] pub const fn from_bounds(bounds: LayoutSize, hidpi_factor: DpiScaleFactor) -> Self {
834        let logical_size = LogicalSize::new(bounds.width as f32, bounds.height as f32);
835        Self {
836            logical_size,
837            hidpi_factor,
838        }
839    }
840
841    #[must_use] pub fn get_physical_size(&self) -> PhysicalSize<u32> {
842        self.get_logical_size()
843            .to_physical(self.get_hidpi_factor().inner.get())
844    }
845
846    #[must_use] pub const fn get_logical_size(&self) -> LogicalSize {
847        self.logical_size
848    }
849
850    #[must_use] pub const fn get_hidpi_factor(&self) -> DpiScaleFactor {
851        self.hidpi_factor
852    }
853}
854
855/// Defines the `focus_targeted` node ID for the next frame
856#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
857#[repr(C, u8)]
858pub enum FocusTarget {
859    Id(DomNodeId),
860    Path(FocusTargetPath),
861    Previous,
862    Next,
863    First,
864    Last,
865    NoFocus,
866}
867
868#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
869#[repr(C)]
870pub struct FocusTargetPath {
871    pub dom: DomId,
872    pub css_path: CssPath,
873}
874
875// -- normal callback
876
877// core callback types (usize-based placeholders)
878//
879// These types use `usize` instead of function pointers to avoid creating
880// a circular dependency between azul-core and azul-layout.
881//
882// The actual function pointers will be stored in azul-layout, which will
883// use unsafe code to transmute between usize and the real function pointers.
884//
885// IMPORTANT: The memory layout must be identical to the real types!
886//
887// Naming convention: "Core" prefix indicates these are the low-level types
888
889/// Core callback type - uses usize instead of function pointer to avoid circular dependencies.
890///
891/// **IMPORTANT**: This is NOT actually a usize at runtime - it's a function pointer that is
892/// cast to usize for storage in the data model. When invoking the callback, this usize is
893/// unsafely cast back to the actual function pointer type:
894/// `extern "C" fn(RefAny, CallbackInfo) -> Update`
895///
896/// This design allows azul-core to store callbacks without depending on azul-layout's `CallbackInfo`
897/// type. The actual function pointer type is defined in azul-layout as `CallbackType`.
898pub type CoreCallbackType = usize;
899
900/// Stores a callback as usize (actually a function pointer cast to usize)
901///
902/// **IMPORTANT**: The `cb` field stores a function pointer disguised as usize to avoid
903/// circular dependencies between azul-core and azul-layout. When creating a `CoreCallback`,
904/// you can directly assign a function pointer - Rust will implicitly cast it to usize.
905/// When invoking, the usize must be unsafely cast back to the function pointer type.
906///
907/// Must return an `Update` that denotes if the screen should be redrawn.
908#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
909#[repr(C)]
910pub struct CoreCallback {
911    pub cb: CoreCallbackType,
912    /// For FFI: stores the foreign callable (e.g., `PyFunction`)
913    /// Native Rust code sets this to None
914    pub ctx: OptionRefAny,
915}
916
917/// Allow creating `CoreCallback` from a raw function pointer (as usize)
918/// Sets callable to None (for native Rust/C usage)
919impl From<CoreCallbackType> for CoreCallback {
920    fn from(cb: CoreCallbackType) -> Self {
921        Self {
922            cb,
923            ctx: OptionRefAny::None,
924        }
925    }
926}
927
928impl_option!(
929    CoreCallback,
930    OptionCoreCallback,
931    [Debug, Eq, Clone, PartialEq, PartialOrd, Ord, Hash]
932);
933
934/// Data associated with a callback (event filter, callback, and user data)
935#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
936#[repr(C)]
937pub struct CoreCallbackData {
938    pub event: EventFilter,
939    pub callback: CoreCallback,
940    pub refany: RefAny,
941}
942
943impl_option!(
944    CoreCallbackData,
945    OptionCoreCallbackData,
946    copy = false,
947    [Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
948);
949
950impl_vec!(CoreCallbackData, CoreCallbackDataVec, CoreCallbackDataVecDestructor, CoreCallbackDataVecDestructorType, CoreCallbackDataVecSlice, OptionCoreCallbackData);
951impl_vec_clone!(
952    CoreCallbackData,
953    CoreCallbackDataVec,
954    CoreCallbackDataVecDestructor
955);
956impl_vec_mut!(CoreCallbackData, CoreCallbackDataVec);
957impl_vec_debug!(CoreCallbackData, CoreCallbackDataVec);
958impl_vec_partialord!(CoreCallbackData, CoreCallbackDataVec);
959impl_vec_ord!(CoreCallbackData, CoreCallbackDataVec);
960impl_vec_partialeq!(CoreCallbackData, CoreCallbackDataVec);
961impl_vec_eq!(CoreCallbackData, CoreCallbackDataVec);
962impl_vec_hash!(CoreCallbackData, CoreCallbackDataVec);
963
964impl CoreCallbackDataVec {
965    #[inline]
966    #[must_use] pub fn as_container(&self) -> NodeDataContainerRef<'_, CoreCallbackData> {
967        NodeDataContainerRef {
968            internal: self.as_ref(),
969        }
970    }
971    #[inline]
972    pub fn as_container_mut(&mut self) -> NodeDataContainerRefMut<'_, CoreCallbackData> {
973        NodeDataContainerRefMut {
974            internal: self.as_mut(),
975        }
976    }
977}
978
979// -- image rendering callback
980
981/// Image rendering callback type - uses usize instead of function pointer
982pub type CoreRenderImageCallbackType = usize;
983
984/// Callback that returns a rendered OpenGL texture (usize placeholder)
985#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
986#[repr(C)]
987pub struct CoreRenderImageCallback {
988    pub cb: CoreRenderImageCallbackType,
989    /// For FFI: stores the foreign callable (e.g., `PyFunction`)
990    /// Native Rust code sets this to None
991    pub ctx: OptionRefAny,
992}
993
994/// Allow creating `CoreRenderImageCallback` from a raw function pointer (as usize)
995/// Sets callable to None (for native Rust/C usage)
996impl From<CoreRenderImageCallbackType> for CoreRenderImageCallback {
997    fn from(cb: CoreRenderImageCallbackType) -> Self {
998        Self {
999            cb,
1000            ctx: OptionRefAny::None,
1001        }
1002    }
1003}
1004
1005/// Image callback with associated data
1006#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
1007#[repr(C)]
1008pub struct CoreImageCallback {
1009    pub refany: RefAny,
1010    pub callback: CoreRenderImageCallback,
1011}
1012
1013impl_option!(
1014    CoreImageCallback,
1015    OptionCoreImageCallback,
1016    copy = false,
1017    [Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
1018);
1019
1020#[cfg(test)]
1021#[allow(
1022    clippy::float_cmp,
1023    clippy::too_many_lines,
1024    clippy::cast_precision_loss,
1025    clippy::unusual_byte_groupings
1026)]
1027mod autotest_generated {
1028    use alloc::string::String;
1029
1030    use super::*;
1031    use crate::{
1032        events::HoverEventFilter,
1033        resources::{RawImageFormat, RouteMatch},
1034        window::StringPairVec,
1035    };
1036
1037    // ---- helpers -----------------------------------------------------------
1038
1039    fn s(v: &str) -> AzString {
1040        AzString::from(String::from(v))
1041    }
1042
1043    fn win(width: f32, height: f32, dpi: u32) -> WindowSize {
1044        WindowSize {
1045            dimensions: LogicalSize::new(width, height),
1046            dpi,
1047            min_dimensions: None.into(),
1048            max_dimensions: None.into(),
1049        }
1050    }
1051
1052    /// Owns everything a `LayoutCallbackInfoRefData` borrows, so that the raw
1053    /// pointer `LayoutCallbackInfo` launders to `'static` always points at
1054    /// live memory for the duration of a test.
1055    struct Fixture {
1056        fonts: FcFontCache,
1057        images: ImageCache,
1058        style: Arc<SystemStyle>,
1059        gl: OptionGlContextPtr,
1060        route: Option<RouteMatch>,
1061    }
1062
1063    impl Fixture {
1064        fn new() -> Self {
1065            Self {
1066                fonts: FcFontCache::default(),
1067                images: ImageCache::default(),
1068                style: Arc::new(SystemStyle::default()),
1069                gl: OptionGlContextPtr::None,
1070                route: None,
1071            }
1072        }
1073
1074        fn with_route(route: RouteMatch) -> Self {
1075            let mut f = Self::new();
1076            f.route = Some(route);
1077            f
1078        }
1079
1080        fn ref_data(&self) -> LayoutCallbackInfoRefData<'_> {
1081            LayoutCallbackInfoRefData {
1082                image_cache: &self.images,
1083                gl_context: &self.gl,
1084                system_fonts: &self.fonts,
1085                system_style: self.style.clone(),
1086                active_route: self.route.as_ref(),
1087            }
1088        }
1089    }
1090
1091    /// `/user/:id` with a plain and a non-ASCII parameter.
1092    fn user_route() -> RouteMatch {
1093        RouteMatch {
1094            pattern: s("/user/:id"),
1095            params: StringPairVec::from_vec(Vec::from([
1096                AzStringPair {
1097                    key: s("id"),
1098                    value: s("42"),
1099                },
1100                AzStringPair {
1101                    key: s("\u{1F600}"),
1102                    value: s("emoji"),
1103                },
1104            ])),
1105        }
1106    }
1107
1108    fn vv_info<'a>(
1109        fonts: &'a FcFontCache,
1110        images: &'a ImageCache,
1111        bounds: HidpiAdjustedBounds,
1112    ) -> VirtualViewCallbackInfo {
1113        VirtualViewCallbackInfo::new(
1114            VirtualViewCallbackReason::InitialRender,
1115            fonts,
1116            images,
1117            WindowTheme::LightMode,
1118            bounds,
1119            LogicalSize::new(100.0, 200.0),
1120            LogicalPosition::new(1.0, 2.0),
1121            LogicalSize::new(1000.0, 2000.0),
1122            LogicalPosition::new(3.0, 4.0),
1123        )
1124    }
1125
1126    fn bounds_1x1() -> HidpiAdjustedBounds {
1127        HidpiAdjustedBounds::from_bounds(LayoutSize::new(1, 1), DpiScaleFactor::new(1.0))
1128    }
1129
1130    // ---- Update::max_self --------------------------------------------------
1131
1132    const ALL_UPDATES: [Update; 3] = [
1133        Update::DoNothing,
1134        Update::RefreshDom,
1135        Update::RefreshDomAllWindows,
1136    ];
1137
1138    /// `max_self` must be exactly the `Ord`-max of the lattice, for every one
1139    /// of the 3x3 combinations (this is the whole contract, so check it
1140    /// exhaustively rather than sampling).
1141    #[test]
1142    fn update_max_self_is_exhaustively_ord_max() {
1143        for a in ALL_UPDATES {
1144            for b in ALL_UPDATES {
1145                let mut got = a;
1146                got.max_self(b);
1147                assert_eq!(
1148                    got,
1149                    core::cmp::max(a, b),
1150                    "max_self({a:?}, {b:?}) disagrees with Ord::max"
1151                );
1152            }
1153        }
1154    }
1155
1156    #[test]
1157    fn update_max_self_is_idempotent_and_monotone() {
1158        for a in ALL_UPDATES {
1159            // idempotent: x.max(x) == x
1160            let mut got = a;
1161            got.max_self(a);
1162            assert_eq!(got, a);
1163
1164            // absorbing top element: nothing can lower RefreshDomAllWindows
1165            let mut top = Update::RefreshDomAllWindows;
1166            top.max_self(a);
1167            assert_eq!(top, Update::RefreshDomAllWindows);
1168
1169            // monotone: max_self never decreases self
1170            let mut m = a;
1171            m.max_self(Update::DoNothing);
1172            assert!(m >= a);
1173        }
1174    }
1175
1176    /// Applying the same set of updates in any order must converge to the same
1177    /// value (commutativity/associativity of the fold), since callbacks fold
1178    /// their `Update`s in nondeterministic order.
1179    #[test]
1180    fn update_max_self_fold_is_order_independent() {
1181        for a in ALL_UPDATES {
1182            for b in ALL_UPDATES {
1183                for c in ALL_UPDATES {
1184                    let mut fwd = a;
1185                    fwd.max_self(b);
1186                    fwd.max_self(c);
1187
1188                    let mut rev = c;
1189                    rev.max_self(b);
1190                    rev.max_self(a);
1191
1192                    assert_eq!(fwd, rev, "fold of {a:?},{b:?},{c:?} is order-dependent");
1193                }
1194            }
1195        }
1196    }
1197
1198    // ---- LayoutCallback / default_layout_callback ---------------------------
1199
1200    static ALT_LAYOUT_CALLS: AtomicUsize = AtomicUsize::new(0);
1201
1202    // NOTE: the body must differ from `default_layout_callback`'s, otherwise
1203    // identical-code-folding may merge the two symbols and the pointer
1204    // inequality assertion below would compare equal addresses.
1205    extern "C" fn alt_layout_callback(_: RefAny, _: LayoutCallbackInfo) -> Dom {
1206        ALT_LAYOUT_CALLS.fetch_add(1, AtomicOrdering::SeqCst);
1207        Dom::create_body()
1208    }
1209
1210    #[test]
1211    fn default_layout_callback_returns_body_and_does_not_panic() {
1212        let fx = Fixture::new();
1213        let rd = fx.ref_data();
1214        let info = LayoutCallbackInfo::new(&rd, win(0.0, 0.0, 0), WindowTheme::DarkMode);
1215
1216        // extreme arg: zero-sized window, zero DPI, empty caches
1217        let dom = default_layout_callback(RefAny::new(0u32), info);
1218        assert_eq!(dom, Dom::create_body());
1219    }
1220
1221    #[test]
1222    fn layout_callback_create_stores_the_given_fn_and_null_ctx() {
1223        let from_default = LayoutCallback::create(default_layout_callback as LayoutCallbackType);
1224        assert!(
1225            from_default.ctx.is_none(),
1226            "native-Rust create() must leave the FFI ctx empty"
1227        );
1228        assert_eq!(from_default, LayoutCallback::default());
1229
1230        // create() must actually store its argument, not silently fall back
1231        // to the default callback.
1232        let from_alt = LayoutCallback::create(alt_layout_callback as LayoutCallbackType);
1233        assert!(from_alt.ctx.is_none());
1234        assert_ne!(
1235            from_alt, from_default,
1236            "create() ignored its argument (or the two fns were ICF-folded)"
1237        );
1238
1239        // the stored pointer is callable and is the one we passed in
1240        let fx = Fixture::new();
1241        let rd = fx.ref_data();
1242        let before = ALT_LAYOUT_CALLS.load(AtomicOrdering::SeqCst);
1243        let info = LayoutCallbackInfo::new(&rd, WindowSize::default(), WindowTheme::LightMode);
1244        let _ = (from_alt.cb)(RefAny::new(()), info);
1245        assert_eq!(ALT_LAYOUT_CALLS.load(AtomicOrdering::SeqCst), before + 1);
1246    }
1247
1248    // ---- VirtualViewCallback ------------------------------------------------
1249
1250    extern "C" fn vv_keep_current_cb(_: RefAny, info: VirtualViewCallbackInfo) -> VirtualViewReturn {
1251        VirtualViewReturn::keep_current(
1252            info.scroll_size,
1253            info.scroll_offset,
1254            info.virtual_scroll_size,
1255            info.virtual_scroll_offset,
1256        )
1257    }
1258
1259    #[test]
1260    fn virtual_view_callback_create_round_trips_through_the_fn_ptr() {
1261        let cb = VirtualViewCallback::create(vv_keep_current_cb as VirtualViewCallbackType);
1262        assert!(cb.ctx.is_none());
1263
1264        let fonts = FcFontCache::default();
1265        let images = ImageCache::default();
1266        let info = vv_info(&fonts, &images, bounds_1x1());
1267
1268        let ret = (cb.cb)(RefAny::new(0u8), info);
1269        assert!(ret.dom.is_none());
1270        assert_eq!(ret.scroll_size, LogicalSize::new(100.0, 200.0));
1271        assert_eq!(ret.scroll_offset, LogicalPosition::new(1.0, 2.0));
1272        assert_eq!(ret.virtual_scroll_size, LogicalSize::new(1000.0, 2000.0));
1273        assert_eq!(ret.virtual_scroll_offset, LogicalPosition::new(3.0, 4.0));
1274    }
1275
1276    // ---- VirtualViewCallbackInfo -------------------------------------------
1277
1278    #[test]
1279    fn virtual_view_callback_info_new_holds_its_fields() {
1280        let fonts = FcFontCache::default();
1281        let images = ImageCache::default();
1282        let bounds = HidpiAdjustedBounds::from_bounds(
1283            LayoutSize::new(800, 600),
1284            DpiScaleFactor::new(2.0),
1285        );
1286        let info = vv_info(&fonts, &images, bounds);
1287
1288        assert_eq!(info.reason, VirtualViewCallbackReason::InitialRender);
1289        assert_eq!(info.window_theme, WindowTheme::LightMode);
1290        assert_eq!(info.get_bounds().get_logical_size(), LogicalSize::new(800.0, 600.0));
1291        assert_eq!(info.get_bounds().get_hidpi_factor(), DpiScaleFactor::new(2.0));
1292        assert_eq!(info.scroll_size, LogicalSize::new(100.0, 200.0));
1293
1294        // the raw pointers must alias the borrows we handed in
1295        assert!(core::ptr::eq(info.internal_get_system_fonts(), &fonts));
1296        assert!(core::ptr::eq(info.internal_get_image_cache(), &images));
1297
1298        // FFI ctx starts empty and the measure hook starts absent
1299        assert!(info.get_ctx().is_none());
1300        assert_eq!(
1301            info.measure_dom(Dom::create_body(), LogicalSize::new(10.0, 10.0)),
1302            LogicalSize::zero()
1303        );
1304
1305        // clone must not disturb any of that
1306        let cloned = info.clone();
1307        assert_eq!(cloned.reason, info.reason);
1308        assert!(core::ptr::eq(cloned.internal_get_system_fonts(), &fonts));
1309        assert!(cloned.get_ctx().is_none());
1310    }
1311
1312    #[test]
1313    fn virtual_view_callback_info_new_survives_nan_and_infinite_geometry() {
1314        let fonts = FcFontCache::default();
1315        let images = ImageCache::default();
1316        let info = VirtualViewCallbackInfo::new(
1317            VirtualViewCallbackReason::EdgeScrolled(EdgeType::Bottom),
1318            &fonts,
1319            &images,
1320            WindowTheme::DarkMode,
1321            HidpiAdjustedBounds::from_bounds(
1322                LayoutSize::new(isize::MAX, isize::MIN),
1323                DpiScaleFactor::new(f32::NAN),
1324            ),
1325            LogicalSize::new(f32::NAN, f32::INFINITY),
1326            LogicalPosition::new(f32::NEG_INFINITY, f32::MAX),
1327            LogicalSize::new(f32::MIN, 0.0),
1328            LogicalPosition::new(-0.0, f32::EPSILON),
1329        );
1330
1331        // extreme values are stored verbatim, not silently clamped
1332        assert!(info.scroll_size.width.is_nan());
1333        assert!(info.scroll_size.height.is_infinite());
1334        assert!(info.scroll_offset.x.is_infinite() && info.scroll_offset.x.is_sign_negative());
1335        assert_eq!(info.virtual_scroll_size.width, f32::MIN);
1336        assert_eq!(info.reason, VirtualViewCallbackReason::EdgeScrolled(EdgeType::Bottom));
1337
1338        // and none of the getters panic on that instance
1339        assert!(info.get_ctx().is_none());
1340        assert!(info.get_bounds().get_logical_size().width > 0.0);
1341    }
1342
1343    #[test]
1344    fn virtual_view_callback_info_get_ctx_clones_without_double_free() {
1345        let fonts = FcFontCache::default();
1346        let images = ImageCache::default();
1347        let mut info = vv_info(&fonts, &images, bounds_1x1());
1348
1349        // null callable_ptr -> None (the native-Rust path)
1350        assert!(info.get_ctx().is_none());
1351
1352        let callable = OptionRefAny::Some(RefAny::new(0xDEAD_BEEF_u32));
1353        info.set_callable_ptr(&callable);
1354
1355        // repeated get_ctx() must hand out independent clones; dropping them
1356        // all must not corrupt the original RefAny's refcount.
1357        for _ in 0..64 {
1358            let got = info.get_ctx();
1359            assert!(got.is_some());
1360            drop(got);
1361        }
1362
1363        let mut got = info.get_ctx();
1364        match got {
1365            OptionRefAny::Some(ref mut r) => {
1366                let inner = r.downcast_ref::<u32>().expect("ctx should hold a u32");
1367                assert_eq!(*inner, 0xDEAD_BEEF_u32);
1368            }
1369            OptionRefAny::None => panic!("callable_ptr was set, get_ctx() returned None"),
1370        }
1371        drop(got);
1372
1373        // the original is still alive and intact after all those clones dropped
1374        let mut orig = callable;
1375        match orig {
1376            OptionRefAny::Some(ref mut r) => {
1377                assert_eq!(*r.downcast_ref::<u32>().unwrap(), 0xDEAD_BEEF_u32);
1378            }
1379            OptionRefAny::None => panic!("original callable was consumed"),
1380        }
1381    }
1382
1383    // ---- measure_dom --------------------------------------------------------
1384
1385    static MEASURE_CALLS: AtomicUsize = AtomicUsize::new(0);
1386
1387    /// Test trampoline. Per the `MeasureDomFn` contract the `Dom` is passed by
1388    /// pointer and **consumed** (moved out) here.
1389    extern "C" fn test_measure_dom_fn(
1390        ctx: *mut c_void,
1391        dom: *mut Dom,
1392        available: LogicalSize,
1393    ) -> LogicalSize {
1394        MEASURE_CALLS.fetch_add(1, AtomicOrdering::SeqCst);
1395        // SAFETY: `measure_dom` always passes a valid, owned-but-ManuallyDrop
1396        // Dom; taking it by value here is exactly the documented contract.
1397        let dom = unsafe { core::ptr::read(dom) };
1398        drop(dom);
1399        if !ctx.is_null() {
1400            // SAFETY: the only caller below passes a `&mut u32`.
1401            unsafe {
1402                *ctx.cast::<u32>() = 0xABCD;
1403            }
1404        }
1405        LogicalSize::new(available.width * 2.0, available.height / 2.0)
1406    }
1407
1408    #[test]
1409    fn measure_dom_without_hook_returns_zero_for_every_input() {
1410        let fonts = FcFontCache::default();
1411        let images = ImageCache::default();
1412        let info = vv_info(&fonts, &images, bounds_1x1());
1413
1414        // zero / negative / NaN / infinite / huge available sizes must all take
1415        // the null-hook early-out without panicking (and must drop the Dom).
1416        for available in [
1417            LogicalSize::zero(),
1418            LogicalSize::new(-1.0, -1.0),
1419            LogicalSize::new(f32::NAN, f32::NAN),
1420            LogicalSize::new(f32::INFINITY, f32::NEG_INFINITY),
1421            LogicalSize::new(f32::MAX, f32::MIN),
1422            LogicalSize::new(1.0, 1_000_000.0),
1423        ] {
1424            assert_eq!(
1425                info.measure_dom(Dom::create_body(), available),
1426                LogicalSize::zero()
1427            );
1428        }
1429    }
1430
1431    #[test]
1432    fn measure_dom_with_hook_forwards_ctx_and_available_and_consumes_the_dom() {
1433        let fonts = FcFontCache::default();
1434        let images = ImageCache::default();
1435        let mut info = vv_info(&fonts, &images, bounds_1x1());
1436
1437        let mut ctx_val: u32 = 0;
1438        info.set_measure_dom_fn(
1439            test_measure_dom_fn,
1440            core::ptr::from_mut(&mut ctx_val).cast::<c_void>(),
1441        );
1442
1443        // NOTE: `>` not `== before + 1` - other tests share this static and
1444        // run in parallel, so only monotonicity is safe to assert here.
1445        let before = MEASURE_CALLS.load(AtomicOrdering::SeqCst);
1446        let out = info.measure_dom(Dom::create_body(), LogicalSize::new(100.0, 40.0));
1447
1448        assert!(MEASURE_CALLS.load(AtomicOrdering::SeqCst) > before);
1449        assert_eq!(out, LogicalSize::new(200.0, 20.0));
1450        assert_eq!(ctx_val, 0xABCD, "measure ctx pointer was not forwarded");
1451
1452        // the documented virtual-scroll sizing idiom: measure at a huge height
1453        let natural = info.measure_dom(Dom::create_body(), LogicalSize::new(320.0, 1_000_000.0));
1454        assert_eq!(natural, LogicalSize::new(640.0, 500_000.0));
1455
1456        // NaN / infinite constraints reach the hook unmodified and come back
1457        // as NaN/inf rather than panicking or being clamped
1458        let nan = info.measure_dom(Dom::create_body(), LogicalSize::new(f32::NAN, 4.0));
1459        assert!(nan.width.is_nan());
1460        assert_eq!(nan.height, 2.0);
1461
1462        let inf = info.measure_dom(Dom::create_body(), LogicalSize::new(f32::INFINITY, 4.0));
1463        assert!(inf.width.is_infinite());
1464    }
1465
1466    #[test]
1467    fn measure_dom_hook_can_be_replaced_and_last_writer_wins() {
1468        let fonts = FcFontCache::default();
1469        let images = ImageCache::default();
1470        let mut info = vv_info(&fonts, &images, bounds_1x1());
1471
1472        info.set_measure_dom_fn(test_measure_dom_fn, core::ptr::null_mut());
1473        // null ctx must be tolerated by the trampoline contract
1474        let first = info.measure_dom(Dom::create_body(), LogicalSize::new(2.0, 8.0));
1475        assert_eq!(first, LogicalSize::new(4.0, 4.0));
1476
1477        let mut ctx_val: u32 = 0;
1478        info.set_measure_dom_fn(
1479            test_measure_dom_fn,
1480            core::ptr::from_mut(&mut ctx_val).cast::<c_void>(),
1481        );
1482        let second = info.measure_dom(Dom::create_body(), LogicalSize::new(2.0, 8.0));
1483        assert_eq!(second, first);
1484        assert_eq!(ctx_val, 0xABCD);
1485    }
1486
1487    // ---- VirtualViewReturn --------------------------------------------------
1488
1489    #[test]
1490    fn virtual_view_return_with_dom_and_keep_current_hold_their_fields() {
1491        let ss = LogicalSize::new(600.0, 30.0);
1492        let so = LogicalPosition::new(0.0, 300.0);
1493        let vss = LogicalSize::new(600.0, 30_000.0);
1494        let vso = LogicalPosition::zero();
1495
1496        let with = VirtualViewReturn::with_dom(Dom::create_body(), ss, so, vss, vso);
1497        assert!(with.dom.is_some(), "with_dom must produce OptionDom::Some");
1498        assert_eq!(with.scroll_size, ss);
1499        assert_eq!(with.scroll_offset, so);
1500        assert_eq!(with.virtual_scroll_size, vss);
1501        assert_eq!(with.virtual_scroll_offset, vso);
1502        assert_eq!(with.dom, OptionDom::Some(Dom::create_body()));
1503
1504        let keep = VirtualViewReturn::keep_current(ss, so, vss, vso);
1505        assert!(keep.dom.is_none(), "keep_current must produce OptionDom::None");
1506        assert_eq!(keep.scroll_size, ss);
1507        assert_eq!(keep.scroll_offset, so);
1508        assert_eq!(keep.virtual_scroll_size, vss);
1509        assert_eq!(keep.virtual_scroll_offset, vso);
1510
1511        // the two constructors differ *only* in the dom field
1512        assert_ne!(with, keep);
1513
1514        // default is the "keep everything, render nothing" zero value
1515        let d = VirtualViewReturn::default();
1516        assert_eq!(
1517            d,
1518            VirtualViewReturn::keep_current(
1519                LogicalSize::zero(),
1520                LogicalPosition::zero(),
1521                LogicalSize::zero(),
1522                LogicalPosition::zero()
1523            )
1524        );
1525    }
1526
1527    #[test]
1528    fn virtual_view_return_keep_current_passes_extreme_values_through_unclamped() {
1529        // zero
1530        let z = VirtualViewReturn::keep_current(
1531            LogicalSize::zero(),
1532            LogicalPosition::zero(),
1533            LogicalSize::zero(),
1534            LogicalPosition::zero(),
1535        );
1536        assert_eq!(z.scroll_size, LogicalSize::zero());
1537        assert_eq!(z.virtual_scroll_size, LogicalSize::zero());
1538
1539        // negative + f32 limits: stored verbatim (no saturation, no panic)
1540        let n = VirtualViewReturn::keep_current(
1541            LogicalSize::new(-1.0, -0.0),
1542            LogicalPosition::new(f32::MIN, f32::MAX),
1543            LogicalSize::new(f32::MAX, f32::MIN_POSITIVE),
1544            LogicalPosition::new(-f32::EPSILON, 0.0),
1545        );
1546        assert_eq!(n.scroll_size.width, -1.0);
1547        assert_eq!(n.scroll_offset.x, f32::MIN);
1548        assert_eq!(n.scroll_offset.y, f32::MAX);
1549        assert_eq!(n.virtual_scroll_size.width, f32::MAX);
1550        assert_eq!(n.virtual_scroll_size.height, f32::MIN_POSITIVE);
1551
1552        // NaN / inf: stored verbatim; NaN makes the struct unequal to itself
1553        // under PartialEq, so probe the fields directly.
1554        let x = VirtualViewReturn::keep_current(
1555            LogicalSize::new(f32::NAN, f32::INFINITY),
1556            LogicalPosition::new(f32::NEG_INFINITY, f32::NAN),
1557            LogicalSize::new(f32::INFINITY, f32::NAN),
1558            LogicalPosition::new(f32::NAN, f32::NEG_INFINITY),
1559        );
1560        assert!(x.scroll_size.width.is_nan());
1561        assert!(x.scroll_size.height.is_infinite() && x.scroll_size.height.is_sign_positive());
1562        assert!(x.scroll_offset.x.is_infinite() && x.scroll_offset.x.is_sign_negative());
1563        assert!(x.scroll_offset.y.is_nan());
1564        assert!(x.virtual_scroll_offset.y.is_infinite());
1565        assert!(x.dom.is_none());
1566    }
1567
1568    // ---- TimerCallbackReturn ------------------------------------------------
1569
1570    #[test]
1571    fn timer_callback_return_constructors_match_their_documented_flags() {
1572        let c = TimerCallbackReturn::continue_unchanged();
1573        assert_eq!(c.should_update, Update::DoNothing);
1574        assert_eq!(c.should_terminate, TerminateTimer::Continue);
1575
1576        let cr = TimerCallbackReturn::continue_and_refresh_dom();
1577        assert_eq!(cr.should_update, Update::RefreshDom);
1578        assert_eq!(cr.should_terminate, TerminateTimer::Continue);
1579
1580        let t = TimerCallbackReturn::terminate_unchanged();
1581        assert_eq!(t.should_update, Update::DoNothing);
1582        assert_eq!(t.should_terminate, TerminateTimer::Terminate);
1583
1584        let tr = TimerCallbackReturn::terminate_and_refresh_dom();
1585        assert_eq!(tr.should_update, Update::RefreshDom);
1586        assert_eq!(tr.should_terminate, TerminateTimer::Terminate);
1587
1588        // all four are distinct - no constructor is a copy-paste of another
1589        let all = [c, cr, t, tr];
1590        for (i, a) in all.iter().enumerate() {
1591            for (j, b) in all.iter().enumerate() {
1592                assert_eq!(i == j, a == b, "constructors {i} and {j} collide");
1593            }
1594        }
1595
1596        // Default is documented as "continue, no update"
1597        assert_eq!(TimerCallbackReturn::default(), c);
1598    }
1599
1600    #[test]
1601    fn timer_callback_return_create_round_trips_every_flag_combination() {
1602        for u in ALL_UPDATES {
1603            for t in [TerminateTimer::Continue, TerminateTimer::Terminate] {
1604                let r = TimerCallbackReturn::create(u, t);
1605                assert_eq!(r.should_update, u);
1606                assert_eq!(r.should_terminate, t);
1607            }
1608        }
1609
1610        // the named constructors agree with the generic one
1611        assert_eq!(
1612            TimerCallbackReturn::create(Update::DoNothing, TerminateTimer::Continue),
1613            TimerCallbackReturn::continue_unchanged()
1614        );
1615        assert_eq!(
1616            TimerCallbackReturn::create(Update::RefreshDom, TerminateTimer::Terminate),
1617            TimerCallbackReturn::terminate_and_refresh_dom()
1618        );
1619
1620        // RefreshDomAllWindows is reachable through create() even though no
1621        // named constructor exposes it
1622        let all_windows =
1623            TimerCallbackReturn::create(Update::RefreshDomAllWindows, TerminateTimer::Terminate);
1624        assert_eq!(all_windows.should_update, Update::RefreshDomAllWindows);
1625    }
1626
1627    // ---- LayoutCallbackInfo: construction + getters --------------------------
1628
1629    #[test]
1630    fn layout_callback_info_new_defaults_to_initial_reason_and_holds_fields() {
1631        let fx = Fixture::new();
1632        let rd = fx.ref_data();
1633        let info = LayoutCallbackInfo::new(&rd, win(1280.0, 720.0, 192), WindowTheme::DarkMode);
1634
1635        assert_eq!(info.relayout_reason(), RelayoutReason::Initial);
1636        assert_eq!(info.theme, WindowTheme::DarkMode);
1637        assert_eq!(info.get_window_width(), 1280.0);
1638        assert_eq!(info.get_window_height(), 720.0);
1639        assert_eq!(info.get_dpi_factor(), 2.0);
1640        assert!(info.get_ctx().is_none());
1641
1642        // the borrowed resources are reachable through the laundered pointer
1643        assert!(core::ptr::eq(info.internal_get_image_cache(), &fx.images));
1644        assert!(core::ptr::eq(info.internal_get_system_fonts(), &fx.fonts));
1645        assert!(core::ptr::eq(info.internal_get_gl_context(), &fx.gl));
1646        assert!(info.get_gl_context().is_none());
1647    }
1648
1649    #[test]
1650    fn layout_callback_info_new_with_reason_round_trips_every_reason() {
1651        let fx = Fixture::new();
1652        let rd = fx.ref_data();
1653
1654        for reason in [
1655            RelayoutReason::Initial,
1656            RelayoutReason::RefreshDom,
1657            RelayoutReason::Resize,
1658            RelayoutReason::ThemeChange,
1659            RelayoutReason::RouteChange,
1660            RelayoutReason::Other,
1661        ] {
1662            let info = LayoutCallbackInfo::new_with_reason(
1663                &rd,
1664                WindowSize::default(),
1665                WindowTheme::LightMode,
1666                reason,
1667            );
1668            assert_eq!(info.relayout_reason(), reason);
1669            // clone must preserve it
1670            assert_eq!(info.clone().relayout_reason(), reason);
1671        }
1672
1673        assert_eq!(RelayoutReason::default(), RelayoutReason::Initial);
1674    }
1675
1676    #[test]
1677    fn layout_callback_info_get_system_style_shares_the_arc() {
1678        let fx = Fixture::new();
1679        let rd = fx.ref_data();
1680        let info = LayoutCallbackInfo::new(&rd, WindowSize::default(), WindowTheme::LightMode);
1681
1682        let a = info.get_system_style();
1683        let b = info.get_system_style();
1684        // it is a clone of the *same* Arc, not a fresh deep copy
1685        assert!(Arc::ptr_eq(&a, &b));
1686        assert!(Arc::ptr_eq(&a, &fx.style));
1687
1688        // repeated cloning must not leak/underflow the refcount
1689        let before = Arc::strong_count(&fx.style);
1690        for _ in 0..128 {
1691            drop(info.get_system_style());
1692        }
1693        assert_eq!(Arc::strong_count(&fx.style), before);
1694    }
1695
1696    #[test]
1697    fn layout_callback_info_get_ctx_is_none_until_set_then_clones_safely() {
1698        let fx = Fixture::new();
1699        let rd = fx.ref_data();
1700        let mut info = LayoutCallbackInfo::new(&rd, WindowSize::default(), WindowTheme::LightMode);
1701
1702        assert!(info.get_ctx().is_none(), "native path must have a null ctx");
1703
1704        let callable = OptionRefAny::Some(RefAny::new(7u64));
1705        info.set_callable_ptr(&callable);
1706
1707        for _ in 0..64 {
1708            assert!(info.get_ctx().is_some());
1709        }
1710
1711        let mut got = info.get_ctx();
1712        match got {
1713            OptionRefAny::Some(ref mut r) => assert_eq!(*r.downcast_ref::<u64>().unwrap(), 7),
1714            OptionRefAny::None => panic!("get_ctx() lost the callable"),
1715        }
1716        drop(got);
1717
1718        // a clone of the info keeps pointing at the same callable
1719        let cloned = info.clone();
1720        assert!(cloned.get_ctx().is_some());
1721    }
1722
1723    #[test]
1724    fn layout_callback_info_get_system_fonts_is_empty_for_an_empty_cache() {
1725        let fx = Fixture::new();
1726        let rd = fx.ref_data();
1727        let info = LayoutCallbackInfo::new(&rd, WindowSize::default(), WindowTheme::LightMode);
1728
1729        // an empty FcFontCache must yield an empty list, not panic
1730        let fonts: Vec<AzStringPair> = info.get_system_fonts();
1731        assert!(fonts.is_empty());
1732        // and be stable across calls
1733        assert_eq!(info.get_system_fonts().len(), fonts.len());
1734    }
1735
1736    // ---- LayoutCallbackInfo::get_image --------------------------------------
1737
1738    #[test]
1739    fn get_image_returns_none_for_missing_empty_and_hostile_ids() {
1740        let fx = Fixture::new();
1741        let rd = fx.ref_data();
1742        let info = LayoutCallbackInfo::new(&rd, WindowSize::default(), WindowTheme::LightMode);
1743
1744        assert!(info.get_image(&s("")).is_none());
1745        assert!(info.get_image(&s("   ")).is_none());
1746        assert!(info.get_image(&s("nope")).is_none());
1747        assert!(info.get_image(&s("\u{1F600}\u{0301}")).is_none());
1748        assert!(info.get_image(&s("\0")).is_none());
1749        assert!(info.get_image(&s(&"x".repeat(100_000))).is_none());
1750    }
1751
1752    #[test]
1753    fn get_image_finds_an_inserted_id_and_is_exact_match() {
1754        let mut fx = Fixture::new();
1755        fx.images.add_css_image_id(
1756            s("logo"),
1757            ImageRef::null_image(2, 2, RawImageFormat::RGBA8, Vec::new()),
1758        );
1759        let rd = fx.ref_data();
1760        let info = LayoutCallbackInfo::new(&rd, WindowSize::default(), WindowTheme::LightMode);
1761
1762        assert!(info.get_image(&s("logo")).is_some(), "positive control");
1763
1764        // lookup is exact: no trimming, no case folding, no prefix matching
1765        assert!(info.get_image(&s("Logo")).is_none());
1766        assert!(info.get_image(&s(" logo")).is_none());
1767        assert!(info.get_image(&s("logo ")).is_none());
1768        assert!(info.get_image(&s("log")).is_none());
1769        assert!(info.get_image(&s("logos")).is_none());
1770    }
1771
1772    // ---- LayoutCallbackInfo::get_active_route / get_route_param -------------
1773
1774    #[test]
1775    fn get_route_param_returns_none_when_no_route_is_active() {
1776        let fx = Fixture::new();
1777        let rd = fx.ref_data();
1778        let info = LayoutCallbackInfo::new(&rd, WindowSize::default(), WindowTheme::LightMode);
1779
1780        assert!(info.get_active_route().is_none());
1781
1782        // every hostile key must take the `?` early-out, never panic
1783        for key in ["", " ", "\t\n", "id", "\u{1F600}", "\0", "../../etc/passwd"] {
1784            assert!(info.get_route_param(key).is_none(), "key {key:?}");
1785        }
1786    }
1787
1788    #[test]
1789    fn get_route_param_valid_minimal_and_unicode_positive_controls() {
1790        let fx = Fixture::with_route(user_route());
1791        let rd = fx.ref_data();
1792        let info = LayoutCallbackInfo::new(&rd, WindowSize::default(), WindowTheme::LightMode);
1793
1794        let route = info.get_active_route().expect("route was configured");
1795        assert_eq!(route.pattern.as_str(), "/user/:id");
1796
1797        // positive control
1798        assert_eq!(info.get_route_param("id").map(AzString::as_str), Some("42"));
1799        // multibyte key round-trips
1800        assert_eq!(
1801            info.get_route_param("\u{1F600}").map(AzString::as_str),
1802            Some("emoji")
1803        );
1804    }
1805
1806    #[test]
1807    fn get_route_param_rejects_malformed_keys_without_trimming_or_folding() {
1808        let fx = Fixture::with_route(user_route());
1809        let rd = fx.ref_data();
1810        let info = LayoutCallbackInfo::new(&rd, WindowSize::default(), WindowTheme::LightMode);
1811
1812        // empty / whitespace-only
1813        assert!(info.get_route_param("").is_none());
1814        assert!(info.get_route_param("   ").is_none());
1815        assert!(info.get_route_param("\t\n").is_none());
1816
1817        // leading/trailing junk is NOT trimmed, and lookup is case-sensitive
1818        assert!(info.get_route_param(" id").is_none());
1819        assert!(info.get_route_param("id ").is_none());
1820        assert!(info.get_route_param("  id  ").is_none());
1821        assert!(info.get_route_param("id;garbage").is_none());
1822        assert!(info.get_route_param("ID").is_none());
1823        assert!(info.get_route_param("Id").is_none());
1824
1825        // no prefix / substring matching
1826        assert!(info.get_route_param("i").is_none());
1827        assert!(info.get_route_param("idd").is_none());
1828
1829        // garbage bytes, NUL, control chars
1830        assert!(info.get_route_param("\0").is_none());
1831        assert!(info.get_route_param("id\0").is_none());
1832        assert!(info.get_route_param("\u{7F}\u{1}\u{2}").is_none());
1833
1834        // boundary numeric strings
1835        for key in [
1836            "0",
1837            "-0",
1838            "9223372036854775807",
1839            "-9223372036854775808",
1840            "18446744073709551616",
1841            "NaN",
1842            "inf",
1843            "-inf",
1844            "1e400",
1845            "0.0000000000000000001",
1846        ] {
1847            assert!(info.get_route_param(key).is_none(), "key {key:?}");
1848        }
1849
1850        // non-ASCII that is *not* a param, incl. combining marks
1851        assert!(info.get_route_param("i\u{0301}d").is_none());
1852        assert!(info.get_route_param("\u{1F600}\u{1F600}").is_none());
1853    }
1854
1855    #[test]
1856    fn get_route_param_handles_pathological_key_sizes_and_nesting() {
1857        let fx = Fixture::with_route(user_route());
1858        let rd = fx.ref_data();
1859        let info = LayoutCallbackInfo::new(&rd, WindowSize::default(), WindowTheme::LightMode);
1860
1861        // extremely long key: must return None quickly, not hang or overflow
1862        let huge = "x".repeat(1_000_000);
1863        assert!(info.get_route_param(&huge).is_none());
1864
1865        // a long key that *shares a prefix* with a real param
1866        let long_id = alloc::format!("id{}", "0".repeat(1_000_000));
1867        assert!(info.get_route_param(&long_id).is_none());
1868
1869        // deeply nested brackets: the lookup is a flat scan, so this must not
1870        // recurse or stack-overflow
1871        let nested = "[".repeat(10_000) + &"]".repeat(10_000);
1872        assert!(info.get_route_param(&nested).is_none());
1873    }
1874
1875    #[test]
1876    fn get_route_param_preserves_huge_and_unicode_values() {
1877        let big = "v".repeat(200_000);
1878        let route = RouteMatch {
1879            pattern: s("/blob/:data"),
1880            params: StringPairVec::from_vec(Vec::from([AzStringPair {
1881                key: s("data"),
1882                value: s(&big),
1883            }])),
1884        };
1885        let fx = Fixture::with_route(route);
1886        let rd = fx.ref_data();
1887        let info = LayoutCallbackInfo::new(&rd, WindowSize::default(), WindowTheme::LightMode);
1888
1889        let got = info.get_route_param("data").expect("param exists");
1890        assert_eq!(got.as_str().len(), 200_000);
1891    }
1892
1893    // ---- LayoutCallbackInfo: responsive predicates ---------------------------
1894
1895    #[test]
1896    fn window_predicates_obey_trichotomy_and_the_between_identity() {
1897        let fx = Fixture::new();
1898        let rd = fx.ref_data();
1899
1900        let probes = [
1901            0.0f32,
1902            -0.0,
1903            1.0,
1904            -1.0,
1905            640.0,
1906            f32::MIN,
1907            f32::MAX,
1908            f32::MIN_POSITIVE,
1909            f32::INFINITY,
1910            f32::NEG_INFINITY,
1911        ];
1912
1913        for &dim in &probes {
1914            let info = LayoutCallbackInfo::new(&rd, win(dim, dim, 96), WindowTheme::LightMode);
1915
1916            for &px in &probes {
1917                let lt = info.window_width_less_than(px);
1918                let gt = info.window_width_greater_than(px);
1919                let eq = info.get_window_width() == px;
1920
1921                // exactly one of <, >, == holds for non-NaN operands
1922                assert_eq!(
1923                    u8::from(lt) + u8::from(gt) + u8::from(eq),
1924                    1,
1925                    "trichotomy broken for width {dim} vs {px}"
1926                );
1927
1928                // height predicates mirror the width ones on a square window
1929                assert_eq!(info.window_height_less_than(px), lt);
1930                assert_eq!(info.window_height_greater_than(px), gt);
1931
1932                for &px2 in &probes {
1933                    // between(a, b) == !(w < a) && !(w > b)
1934                    assert_eq!(
1935                        info.window_width_between(px, px2),
1936                        !info.window_width_less_than(px) && !info.window_width_greater_than(px2),
1937                        "between identity broken for width {dim} in [{px}, {px2}]"
1938                    );
1939                    assert_eq!(
1940                        info.window_height_between(px, px2),
1941                        info.window_width_between(px, px2)
1942                    );
1943                }
1944            }
1945        }
1946    }
1947
1948    #[test]
1949    fn window_predicates_with_inverted_and_degenerate_ranges() {
1950        let fx = Fixture::new();
1951        let rd = fx.ref_data();
1952        let info = LayoutCallbackInfo::new(&rd, win(640.0, 480.0, 96), WindowTheme::LightMode);
1953
1954        // inverted range is always empty
1955        assert!(!info.window_width_between(1000.0, 100.0));
1956        assert!(!info.window_height_between(1000.0, 100.0));
1957
1958        // degenerate (min == max) range is inclusive on both ends
1959        assert!(info.window_width_between(640.0, 640.0));
1960        assert!(info.window_height_between(480.0, 480.0));
1961        assert!(!info.window_width_between(639.9, 639.95));
1962
1963        // inclusive boundaries
1964        assert!(info.window_width_between(640.0, 1000.0));
1965        assert!(info.window_width_between(0.0, 640.0));
1966
1967        // strictness at the exact boundary
1968        assert!(!info.window_width_less_than(640.0));
1969        assert!(!info.window_width_greater_than(640.0));
1970        assert!(info.window_width_less_than(640.001));
1971        assert!(info.window_width_greater_than(639.999));
1972
1973        // the widest possible range contains a finite width
1974        assert!(info.window_width_between(f32::NEG_INFINITY, f32::INFINITY));
1975    }
1976
1977    #[test]
1978    fn window_predicates_are_all_false_for_nan_probes() {
1979        let fx = Fixture::new();
1980        let rd = fx.ref_data();
1981        let info = LayoutCallbackInfo::new(&rd, win(640.0, 480.0, 96), WindowTheme::LightMode);
1982
1983        // every comparison against NaN is false - no panic, no accidental `true`
1984        assert!(!info.window_width_less_than(f32::NAN));
1985        assert!(!info.window_width_greater_than(f32::NAN));
1986        assert!(!info.window_width_between(f32::NAN, f32::NAN));
1987        assert!(!info.window_width_between(f32::NAN, 10_000.0));
1988        assert!(!info.window_width_between(0.0, f32::NAN));
1989
1990        assert!(!info.window_height_less_than(f32::NAN));
1991        assert!(!info.window_height_greater_than(f32::NAN));
1992        assert!(!info.window_height_between(f32::NAN, f32::NAN));
1993        assert!(!info.window_height_between(f32::NAN, 10_000.0));
1994        assert!(!info.window_height_between(0.0, f32::NAN));
1995    }
1996
1997    #[test]
1998    fn window_predicates_are_all_false_for_a_nan_sized_window() {
1999        let fx = Fixture::new();
2000        let rd = fx.ref_data();
2001        let info = LayoutCallbackInfo::new(
2002            &rd,
2003            win(f32::NAN, f32::NAN, 96),
2004            WindowTheme::LightMode,
2005        );
2006
2007        assert!(info.get_window_width().is_nan());
2008        assert!(info.get_window_height().is_nan());
2009
2010        // a NaN window is neither smaller, larger, nor within any range
2011        for px in [0.0f32, 640.0, f32::MAX, f32::INFINITY, f32::NEG_INFINITY] {
2012            assert!(!info.window_width_less_than(px));
2013            assert!(!info.window_width_greater_than(px));
2014            assert!(!info.window_height_less_than(px));
2015            assert!(!info.window_height_greater_than(px));
2016            assert!(!info.window_width_between(f32::NEG_INFINITY, px));
2017            assert!(!info.window_height_between(px, f32::INFINITY));
2018        }
2019    }
2020
2021    #[test]
2022    fn get_dpi_factor_at_zero_and_u32_limits() {
2023        let fx = Fixture::new();
2024        let rd = fx.ref_data();
2025
2026        // 96 DPI is the 1.0 baseline
2027        let base = LayoutCallbackInfo::new(&rd, win(1.0, 1.0, 96), WindowTheme::LightMode);
2028        assert_eq!(base.get_dpi_factor(), 1.0);
2029
2030        let hidpi = LayoutCallbackInfo::new(&rd, win(1.0, 1.0, 192), WindowTheme::LightMode);
2031        assert_eq!(hidpi.get_dpi_factor(), 2.0);
2032
2033        // dpi = 0 must not divide-by-zero-panic; it yields 0.0
2034        let zero = LayoutCallbackInfo::new(&rd, win(1.0, 1.0, 0), WindowTheme::LightMode);
2035        assert_eq!(zero.get_dpi_factor(), 0.0);
2036
2037        // u32::MAX must not overflow the f32 cast - it stays finite
2038        let max = LayoutCallbackInfo::new(&rd, win(1.0, 1.0, u32::MAX), WindowTheme::LightMode);
2039        let f = max.get_dpi_factor();
2040        assert!(f.is_finite() && f > 0.0, "dpi factor {f} is not finite");
2041        assert_eq!(f, (u32::MAX as f32) / 96.0);
2042
2043        // dpi = 1 rounds to a tiny-but-positive factor rather than 0
2044        let one = LayoutCallbackInfo::new(&rd, win(1.0, 1.0, 1), WindowTheme::LightMode);
2045        assert!(one.get_dpi_factor() > 0.0);
2046    }
2047
2048    // ---- HidpiAdjustedBounds -------------------------------------------------
2049
2050    #[test]
2051    fn hidpi_adjusted_bounds_from_bounds_holds_its_fields() {
2052        let b = HidpiAdjustedBounds::from_bounds(
2053            LayoutSize::new(800, 600),
2054            DpiScaleFactor::new(1.5),
2055        );
2056        assert_eq!(b.get_logical_size(), LogicalSize::new(800.0, 600.0));
2057        assert_eq!(b.get_hidpi_factor(), DpiScaleFactor::new(1.5));
2058        assert_eq!(b.logical_size, b.get_logical_size());
2059        assert_eq!(b.hidpi_factor, b.get_hidpi_factor());
2060
2061        let p = b.get_physical_size();
2062        assert_eq!(p.width, 1200);
2063        assert_eq!(p.height, 900);
2064    }
2065
2066    #[test]
2067    fn hidpi_adjusted_bounds_at_zero() {
2068        let b = HidpiAdjustedBounds::from_bounds(LayoutSize::new(0, 0), DpiScaleFactor::new(1.0));
2069        assert_eq!(b.get_logical_size(), LogicalSize::zero());
2070        let p = b.get_physical_size();
2071        assert_eq!(p.width, 0);
2072        assert_eq!(p.height, 0);
2073
2074        // a zero scale factor collapses any size to 0x0 without panicking
2075        let z = HidpiAdjustedBounds::from_bounds(
2076            LayoutSize::new(1920, 1080),
2077            DpiScaleFactor::new(0.0),
2078        );
2079        let zp = z.get_physical_size();
2080        assert_eq!(zp.width, 0);
2081        assert_eq!(zp.height, 0);
2082    }
2083
2084    /// `get_physical_size` funnels through `roundf(x) as u32`, which is a
2085    /// *saturating* float->int cast in Rust: negatives clamp to 0, huge values
2086    /// clamp to u32::MAX, NaN becomes 0. Pin that down so a future refactor to
2087    /// an unchecked cast (UB) or a panicking one is caught.
2088    #[test]
2089    fn hidpi_adjusted_bounds_physical_size_saturates_on_negative_input() {
2090        let b = HidpiAdjustedBounds::from_bounds(
2091            LayoutSize::new(-100, -50),
2092            DpiScaleFactor::new(1.0),
2093        );
2094        assert_eq!(b.get_logical_size(), LogicalSize::new(-100.0, -50.0));
2095
2096        let p = b.get_physical_size();
2097        assert_eq!(p.width, 0, "negative logical width must clamp to 0, not wrap");
2098        assert_eq!(p.height, 0, "negative logical height must clamp to 0, not wrap");
2099
2100        // negative scale factor on a positive size clamps the same way
2101        let neg_scale = HidpiAdjustedBounds::from_bounds(
2102            LayoutSize::new(100, 100),
2103            DpiScaleFactor::new(-2.0),
2104        );
2105        let np = neg_scale.get_physical_size();
2106        assert_eq!(np.width, 0);
2107        assert_eq!(np.height, 0);
2108    }
2109
2110    #[test]
2111    fn hidpi_adjusted_bounds_physical_size_saturates_at_the_upper_limit() {
2112        // isize::MAX logical px * 1.0 overflows u32 -> must saturate, not wrap
2113        let b = HidpiAdjustedBounds::from_bounds(
2114            LayoutSize::new(isize::MAX, isize::MAX),
2115            DpiScaleFactor::new(1.0),
2116        );
2117        let p = b.get_physical_size();
2118        assert_eq!(p.width, u32::MAX);
2119        assert_eq!(p.height, u32::MAX);
2120
2121        // isize::MIN saturates downwards to 0
2122        let min = HidpiAdjustedBounds::from_bounds(
2123            LayoutSize::new(isize::MIN, isize::MIN),
2124            DpiScaleFactor::new(1.0),
2125        );
2126        let mp = min.get_physical_size();
2127        assert_eq!(mp.width, 0);
2128        assert_eq!(mp.height, 0);
2129
2130        // a huge scale factor on a modest size also saturates
2131        let huge_scale = HidpiAdjustedBounds::from_bounds(
2132            LayoutSize::new(1000, 1000),
2133            DpiScaleFactor::new(f32::MAX),
2134        );
2135        let hp = huge_scale.get_physical_size();
2136        assert_eq!(hp.width, u32::MAX);
2137        assert_eq!(hp.height, u32::MAX);
2138    }
2139
2140    /// `DpiScaleFactor` stores its f32 in a fixed-point `isize` (x1000), so
2141    /// NaN quantizes to 0 and +/-inf quantize to the isize limits. Assert the
2142    /// *observable* consequence rather than a panic.
2143    #[test]
2144    fn hidpi_adjusted_bounds_physical_size_with_nan_and_infinite_scale() {
2145        let nan = HidpiAdjustedBounds::from_bounds(
2146            LayoutSize::new(100, 100),
2147            DpiScaleFactor::new(f32::NAN),
2148        );
2149        // NaN -> fixed-point 0 -> 0.0 scale -> 0x0 physical
2150        assert_eq!(nan.get_hidpi_factor().inner.get(), 0.0);
2151        let np = nan.get_physical_size();
2152        assert_eq!(np.width, 0);
2153        assert_eq!(np.height, 0);
2154
2155        let inf = HidpiAdjustedBounds::from_bounds(
2156            LayoutSize::new(100, 100),
2157            DpiScaleFactor::new(f32::INFINITY),
2158        );
2159        // +inf -> saturated fixed-point -> huge (but finite) scale
2160        assert!(inf.get_hidpi_factor().inner.get().is_finite());
2161        let ip = inf.get_physical_size();
2162        assert_eq!(ip.width, u32::MAX);
2163        assert_eq!(ip.height, u32::MAX);
2164
2165        let neg_inf = HidpiAdjustedBounds::from_bounds(
2166            LayoutSize::new(100, 100),
2167            DpiScaleFactor::new(f32::NEG_INFINITY),
2168        );
2169        let nip = neg_inf.get_physical_size();
2170        assert_eq!(nip.width, 0);
2171        assert_eq!(nip.height, 0);
2172    }
2173
2174    #[test]
2175    fn hidpi_adjusted_bounds_physical_size_rounds_to_nearest() {
2176        // 0.5px rounds away from zero (libm::roundf), not truncates
2177        let b = HidpiAdjustedBounds::from_bounds(LayoutSize::new(3, 3), DpiScaleFactor::new(1.5));
2178        let p = b.get_physical_size();
2179        assert_eq!(p.width, 5, "3 * 1.5 = 4.5 must round to 5");
2180        assert_eq!(p.height, 5);
2181
2182        // idempotent: repeated calls give the same answer
2183        let p2 = b.get_physical_size();
2184        assert_eq!(p.width, p2.width);
2185        assert_eq!(p.height, p2.height);
2186    }
2187
2188    // ---- CoreCallbackDataVec -------------------------------------------------
2189
2190    fn cb_data(cb: usize) -> CoreCallbackData {
2191        CoreCallbackData {
2192            event: EventFilter::Hover(HoverEventFilter::MouseOver),
2193            callback: CoreCallback::from(cb),
2194            refany: RefAny::new(cb),
2195        }
2196    }
2197
2198    #[test]
2199    fn core_callback_data_vec_as_container_on_empty_vecs_does_not_panic() {
2200        // both the const-empty and the heap-empty representation must produce
2201        // a valid (length-0) container - a null-ptr slice here would be UB
2202        let empty = CoreCallbackDataVec::new();
2203        assert_eq!(empty.as_container().len(), 0);
2204        assert!(empty.as_container().internal.is_empty());
2205
2206        let from_empty_vec = CoreCallbackDataVec::from_vec(Vec::new());
2207        assert_eq!(from_empty_vec.as_container().len(), 0);
2208
2209        let mut mut_empty = CoreCallbackDataVec::from_vec(Vec::new());
2210        assert!(mut_empty.as_container_mut().internal.is_empty());
2211    }
2212
2213    #[test]
2214    fn core_callback_data_vec_as_container_matches_the_backing_vec() {
2215        let v = CoreCallbackDataVec::from_vec(Vec::from([cb_data(1), cb_data(2), cb_data(3)]));
2216
2217        let c = v.as_container();
2218        assert_eq!(c.len(), 3);
2219        assert_eq!(c.len(), v.len());
2220        assert_eq!(c.internal[0].callback.cb, 1);
2221        assert_eq!(c.internal[2].callback.cb, 3);
2222
2223        // the container borrows - it does not copy
2224        assert!(core::ptr::eq(c.internal.as_ptr(), v.as_slice().as_ptr()));
2225    }
2226
2227    #[test]
2228    fn core_callback_data_vec_as_container_mut_writes_through() {
2229        let mut v = CoreCallbackDataVec::from_vec(Vec::from([cb_data(1), cb_data(2)]));
2230
2231        {
2232            let mut c = v.as_container_mut();
2233            assert_eq!(c.internal.len(), 2);
2234            c.internal[0].callback.cb = 99;
2235            c.internal[1].event = EventFilter::Hover(HoverEventFilter::MouseDown);
2236        }
2237
2238        // mutations are visible through the immutable container
2239        let c = v.as_container();
2240        assert_eq!(c.internal[0].callback.cb, 99);
2241        assert_eq!(
2242            c.internal[1].event,
2243            EventFilter::Hover(HoverEventFilter::MouseDown)
2244        );
2245        assert_eq!(c.len(), 2);
2246    }
2247}