Skip to main content

azul_layout/widgets/
split_pane.rs

1//! Split-pane / splitter widget — a two-pane container (horizontal or vertical)
2//! holding two arbitrary child `Dom`s with a draggable divider between them that
3//! resizes the panes.
4//!
5//! This is [`crate::widgets::frame::Frame`]'s "two bordered boxes" composed with
6//! the pointer-drag state machine of [`crate::widgets::map`] /
7//! [`crate::widgets::slider::Slider`]: the drag callbacks live on the **container**
8//! (so the cursor stays inside the callback node for the whole drag, exactly like
9//! the map's pan), `MouseDown` near the divider begins the drag, `MouseOver` while
10//! dragging recomputes the split ratio from the cursor delta and live-resizes the
11//! two panes via `set_css_property` (`flex-grow`), and `MouseUp` / `MouseLeave`
12//! ends it.
13//!
14//! ## Layout model
15//! The container is a flex row (horizontal split: panes left/right) or column
16//! (vertical split: panes top/bottom). Its three children are
17//! `[first-pane, divider, second-pane]`. Both panes use `flex-basis: 0` and a
18//! `flex-grow` of `ratio` / `1 - ratio`, so they split the container's main-axis
19//! space proportionally while the divider keeps its fixed thickness. Dragging
20//! rewrites the two `flex-grow` values.
21//!
22//! ## Drag tracking (mirrors `map::MapTileCache`)
23//! The transient drag fields (`is_dragging`, `drag_start_px`, `ratio_at_drag_start`)
24//! live in [`SplitPaneStateWrapper`] (not the user-visible [`SplitPaneState`]),
25//! the same way the map keeps `drag_anchor` in its cache. On press we record the
26//! cursor's main-axis position and the ratio at that moment; each move applies
27//! `ratio_at_drag_start + delta / main_size`, so grabbing the divider anywhere
28//! keeps it under the cursor (the map's anchor-delta feel).
29//!
30//! TODO2 / PARTIAL — continuous drag is NOT verifiable in this headless build.
31//! Like `map.rs`'s pan, the live resize depends on the runtime delivering
32//! `MouseOver` (with a node-relative cursor) repeatedly while the button is held,
33//! and on `set_css_property(flex-grow)` triggering a relayout per move — both are
34//! GUI-runtime behaviours with no headless test here. The DOM, the divider, the
35//! proportional `flex-grow` sizing, and the press/move/release wiring all compile
36//! and mirror the proven map/slider pattern exactly; the moment-to-moment motion
37//! is the only unverified part. No motion is faked.
38//!
39//! Key types: [`SplitPane`], [`SplitPaneState`], [`SplitDirection`],
40//! [`SplitPaneOnResize`].
41
42use std::vec::Vec;
43
44use azul_core::{
45    callbacks::{CoreCallbackData, Update},
46    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
47    geom::{CursorNodePosition, LogicalSize},
48    refany::RefAny,
49};
50use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
51use azul_css::{
52    props::{
53        basic::{color::ColorU, FloatValue, PixelValue},
54        layout::{LayoutFlexGrow, LayoutFlexDirection, LayoutDisplay, LayoutWidth, LayoutHeight, LayoutOverflow, LayoutFlexBasis, LayoutMinWidth, LayoutMinHeight, LayoutFlexShrink},
55        property::{CssProperty, LayoutFlexGrowValue, LayoutWidthValue, LayoutHeightValue, LayoutFlexBasisValue},
56        style::{StyleBackgroundContent, StyleBackgroundContentVec, StyleCursor},
57    },
58    impl_option_inner, AzString,
59};
60
61use crate::callbacks::CallbackInfo;
62
63static SPLIT_PANE_CLASS: &[IdOrClass] =
64    &[Class(AzString::from_const_str("__azul-native-split-pane"))];
65static SPLIT_PANE_FIRST_CLASS: &[IdOrClass] =
66    &[Class(AzString::from_const_str("__azul-native-split-pane-first"))];
67static SPLIT_PANE_DIVIDER_CLASS: &[IdOrClass] =
68    &[Class(AzString::from_const_str("__azul-native-split-pane-divider"))];
69static SPLIT_PANE_SECOND_CLASS: &[IdOrClass] =
70    &[Class(AzString::from_const_str("__azul-native-split-pane-second"))];
71
72/// Orientation of a [`SplitPane`].
73#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
74#[repr(C)]
75pub enum SplitDirection {
76    /// Panes side by side (left / right); a vertical divider dragged horizontally.
77    #[default]
78    Horizontal,
79    /// Panes stacked (top / bottom); a horizontal divider dragged vertically.
80    Vertical,
81}
82
83/// Callback function type invoked when the split ratio changes (during a drag).
84pub type SplitPaneOnResizeCallbackType =
85    extern "C" fn(RefAny, CallbackInfo, SplitPaneState) -> Update;
86impl_widget_callback!(
87    SplitPaneOnResize,
88    OptionSplitPaneOnResize,
89    SplitPaneOnResizeCallback,
90    SplitPaneOnResizeCallbackType
91);
92
93azul_core::impl_managed_callback! {
94    wrapper:        SplitPaneOnResizeCallback,
95    info_ty:        CallbackInfo,
96    return_ty:      Update,
97    default_ret:    Update::DoNothing,
98    invoker_static: SPLIT_PANE_ON_RESIZE_INVOKER,
99    invoker_ty:     AzSplitPaneOnResizeCallbackInvoker,
100    thunk_fn:       az_split_pane_on_resize_callback_thunk,
101    setter_fn:      AzApp_setSplitPaneOnResizeCallbackInvoker,
102    from_handle_fn: AzSplitPaneOnResizeCallback_createFromHostHandle,
103    extra_args:     [ state: SplitPaneState ],
104}
105
106/// A two-pane resizable container with a draggable divider.
107#[derive(Debug, Clone)]
108#[repr(C)]
109pub struct SplitPane {
110    pub split_pane_state: SplitPaneStateWrapper,
111    /// The first pane's content (left for horizontal, top for vertical).
112    pub first: Dom,
113    /// The second pane's content (right for horizontal, bottom for vertical).
114    pub second: Dom,
115    /// Style for the outer container.
116    pub container_style: CssPropertyWithConditionsVec,
117}
118
119#[derive(Debug, Default, Clone, PartialEq)]
120#[repr(C)]
121pub struct SplitPaneStateWrapper {
122    /// The user-visible orientation + split ratio.
123    pub inner: SplitPaneState,
124    /// Optional: function to call when the split ratio changes.
125    pub on_resize: OptionSplitPaneOnResize,
126    /// `true` while a divider drag is in flight (mirrors `map::MapTileCache::drag_anchor`).
127    /// Transient — not part of the user-visible [`SplitPaneState`].
128    pub is_dragging: bool,
129    /// Cursor main-axis position (relative to the container) at drag start.
130    pub drag_start_px: f32,
131    /// Split ratio captured at drag start (the anchor for the delta-based update).
132    pub ratio_at_drag_start: f32,
133}
134
135/// State of a [`SplitPane`]: the orientation and the first pane's size fraction.
136#[derive(Debug, Copy, Clone, PartialEq)]
137#[repr(C)]
138pub struct SplitPaneState {
139    /// Orientation of the split.
140    pub direction: SplitDirection,
141    /// Fraction `[0, 1]` of the container's main-axis size taken by the FIRST
142    /// pane. Clamped to `[MIN_RATIO, MAX_RATIO]` so a pane never fully collapses.
143    pub ratio: f32,
144}
145
146impl Default for SplitPaneState {
147    fn default() -> Self {
148        Self {
149            direction: SplitDirection::Horizontal,
150            ratio: 0.5,
151        }
152    }
153}
154
155// ---- dimensions / limits ----
156/// Divider thickness in logical px.
157const DIVIDER_THICKNESS: isize = 6;
158/// How far (logical px) from the divider centre a press still grabs it.
159const GRAB_THRESHOLD: f32 = 9.0;
160/// Smallest / largest allowed first-pane fraction (keeps both panes visible).
161const MIN_RATIO: f32 = 0.05;
162const MAX_RATIO: f32 = 0.95;
163
164// ---- colours ----
165/// Divider colour (#adb5bd, mid grey).
166const DIVIDER_COLOR: ColorU = ColorU { r: 173, g: 181, b: 189, a: 255 };
167
168const DIVIDER_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(DIVIDER_COLOR)];
169const DIVIDER_BG: StyleBackgroundContentVec =
170    StyleBackgroundContentVec::from_const_slice(DIVIDER_BG_ITEMS);
171
172/// `flex-grow: v` as a runtime `CssProperty` (floating-point ratio).
173fn flex_grow_prop(v: f32) -> CssProperty {
174    CssProperty::FlexGrow(LayoutFlexGrowValue::Exact(LayoutFlexGrow {
175        inner: FloatValue::new(v),
176    }))
177}
178
179/// The cursor's main-axis (drag-axis) coordinate for the given direction.
180const fn main_axis(dir: SplitDirection, pos: CursorNodePosition) -> f32 {
181    match dir {
182        SplitDirection::Horizontal => pos.x,
183        SplitDirection::Vertical => pos.y,
184    }
185}
186
187/// The container's main-axis (drag-axis) size for the given direction.
188const fn main_size(dir: SplitDirection, size: LogicalSize) -> f32 {
189    match dir {
190        SplitDirection::Horizontal => size.width,
191        SplitDirection::Vertical => size.height,
192    }
193}
194
195/// Builds the outer-container style: a full-size flex box laid out along the
196/// split's main axis. Overridable via [`SplitPane::with_container_style`].
197fn container_style(dir: SplitDirection) -> CssPropertyWithConditionsVec {
198    let flex_dir = match dir {
199        SplitDirection::Horizontal => LayoutFlexDirection::Row,
200        SplitDirection::Vertical => LayoutFlexDirection::Column,
201    };
202    CssPropertyWithConditionsVec::from_vec(vec![
203        CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
204        CssPropertyWithConditions::simple(CssProperty::const_flex_direction(flex_dir)),
205        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
206        CssPropertyWithConditions::simple(CssProperty::Width(LayoutWidthValue::Exact(
207            LayoutWidth::Px(PixelValue::percent(100.0)),
208        ))),
209        CssPropertyWithConditions::simple(CssProperty::Height(LayoutHeightValue::Exact(
210            LayoutHeight::Px(PixelValue::percent(100.0)),
211        ))),
212        CssPropertyWithConditions::simple(CssProperty::const_overflow_x(LayoutOverflow::Hidden)),
213        CssPropertyWithConditions::simple(CssProperty::const_overflow_y(LayoutOverflow::Hidden)),
214    ])
215}
216
217/// Builds a pane's style: `flex-grow: grow; flex-basis: 0` so the two panes split
218/// the container's main-axis space proportionally, plus `overflow: hidden` and
219/// `min-width/height: 0` so a shrinking pane clips its content instead of forcing
220/// the container wider.
221fn pane_style(grow: f32) -> CssPropertyWithConditionsVec {
222    CssPropertyWithConditionsVec::from_vec(vec![
223        CssPropertyWithConditions::simple(flex_grow_prop(grow)),
224        CssPropertyWithConditions::simple(CssProperty::FlexBasis(LayoutFlexBasisValue::Exact(
225            LayoutFlexBasis::Exact(PixelValue::const_px(0)),
226        ))),
227        CssPropertyWithConditions::simple(CssProperty::const_min_width(LayoutMinWidth::const_px(0))),
228        CssPropertyWithConditions::simple(CssProperty::const_min_height(LayoutMinHeight::const_px(
229            0,
230        ))),
231        CssPropertyWithConditions::simple(CssProperty::const_overflow_x(LayoutOverflow::Hidden)),
232        CssPropertyWithConditions::simple(CssProperty::const_overflow_y(LayoutOverflow::Hidden)),
233    ])
234}
235
236/// Builds the divider's style: fixed thickness, no grow/shrink, a resize cursor
237/// matching the drag axis, and a visible fill. The cross-axis size is left to the
238/// flex default (stretch), so the divider spans the container.
239fn divider_style(dir: SplitDirection) -> CssPropertyWithConditionsVec {
240    let (size_prop, cursor) = match dir {
241        SplitDirection::Horizontal => (
242            CssProperty::const_width(LayoutWidth::const_px(DIVIDER_THICKNESS)),
243            StyleCursor::ColResize,
244        ),
245        SplitDirection::Vertical => (
246            CssProperty::const_height(LayoutHeight::const_px(DIVIDER_THICKNESS)),
247            StyleCursor::RowResize,
248        ),
249    };
250    CssPropertyWithConditionsVec::from_vec(vec![
251        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
252        CssPropertyWithConditions::simple(CssProperty::const_flex_shrink(LayoutFlexShrink {
253            inner: FloatValue::const_new(0),
254        })),
255        CssPropertyWithConditions::simple(size_prop),
256        CssPropertyWithConditions::simple(CssProperty::const_cursor(cursor)),
257        CssPropertyWithConditions::simple(CssProperty::const_background_content(DIVIDER_BG)),
258    ])
259}
260
261impl SplitPane {
262    /// Creates a split pane with the two child `Dom`s, split 50/50.
263    #[must_use] pub fn create(direction: SplitDirection, first: Dom, second: Dom) -> Self {
264        Self {
265            split_pane_state: SplitPaneStateWrapper {
266                inner: SplitPaneState {
267                    direction,
268                    ratio: 0.5,
269                },
270                ..Default::default()
271            },
272            first,
273            second,
274            container_style: container_style(direction),
275        }
276    }
277
278    /// Sets the first-pane fraction, clamped into `[MIN_RATIO, MAX_RATIO]`.
279    #[inline]
280    pub const fn set_ratio(&mut self, ratio: f32) {
281        self.split_pane_state.inner.ratio = ratio.clamp(MIN_RATIO, MAX_RATIO);
282    }
283
284    /// Builder-style setter for the first-pane fraction.
285    #[inline]
286    #[must_use] pub const fn with_ratio(mut self, ratio: f32) -> Self {
287        self.set_ratio(ratio);
288        self
289    }
290
291    /// Sets the orientation (also refreshes the default container style).
292    #[inline]
293    pub fn set_direction(&mut self, direction: SplitDirection) {
294        self.split_pane_state.inner.direction = direction;
295        self.container_style = container_style(direction);
296    }
297
298    /// Builder-style setter for the orientation.
299    #[inline]
300    #[must_use] pub fn with_direction(mut self, direction: SplitDirection) -> Self {
301        self.set_direction(direction);
302        self
303    }
304
305    /// Replaces the default container style.
306    #[inline]
307    #[must_use] pub fn with_container_style(mut self, css: CssPropertyWithConditionsVec) -> Self {
308        self.container_style = css;
309        self
310    }
311
312    #[inline]
313    #[must_use] pub fn swap_with_default(&mut self) -> Self {
314        let mut s = Self::create(
315            SplitDirection::Horizontal,
316            Dom::create_div(),
317            Dom::create_div(),
318        );
319        core::mem::swap(&mut s, self);
320        s
321    }
322
323    #[inline]
324    pub fn set_on_resize<C: Into<SplitPaneOnResizeCallback>>(&mut self, data: RefAny, on_resize: C) {
325        self.split_pane_state.on_resize = Some(SplitPaneOnResize {
326            callback: on_resize.into(),
327            refany: data,
328        })
329        .into();
330    }
331
332    #[inline]
333    #[must_use] pub fn with_on_resize<C: Into<SplitPaneOnResizeCallback>>(
334        mut self,
335        data: RefAny,
336        on_resize: C,
337    ) -> Self {
338        self.set_on_resize(data, on_resize);
339        self
340    }
341
342    #[must_use] pub fn dom(self) -> Dom {
343        use azul_core::{
344            callbacks::CoreCallback,
345            dom::{EventFilter, HoverEventFilter},
346            refany::OptionRefAny,
347        };
348
349        let direction = self.split_pane_state.inner.direction;
350        let ratio = self.split_pane_state.inner.ratio;
351
352        // One shared RefAny across all pointer callbacks so the transient drag
353        // fields set on press are visible to the move/release handlers (RefAny::clone
354        // shares the underlying data — same pattern as map.rs / slider.rs).
355        let state = RefAny::new(self.split_pane_state);
356        let mk = |event: EventFilter, cb: usize| CoreCallbackData {
357            event,
358            callback: CoreCallback {
359                cb,
360                ctx: OptionRefAny::None,
361            },
362            refany: state.clone(),
363        };
364        let callbacks = vec![
365            mk(
366                EventFilter::Hover(HoverEventFilter::MouseDown),
367                on_split_pointer_down as usize,
368            ),
369            mk(
370                EventFilter::Hover(HoverEventFilter::MouseOver),
371                on_split_pointer_move as usize,
372            ),
373            mk(
374                EventFilter::Hover(HoverEventFilter::MouseUp),
375                on_split_pointer_up as usize,
376            ),
377            mk(
378                EventFilter::Hover(HoverEventFilter::MouseLeave),
379                on_split_pointer_up as usize,
380            ),
381            mk(
382                EventFilter::Hover(HoverEventFilter::TouchStart),
383                on_split_pointer_down as usize,
384            ),
385            mk(
386                EventFilter::Hover(HoverEventFilter::TouchMove),
387                on_split_pointer_move as usize,
388            ),
389            mk(
390                EventFilter::Hover(HoverEventFilter::TouchEnd),
391                on_split_pointer_up as usize,
392            ),
393        ];
394
395        // Children: [first-pane, divider, second-pane] — the order the drag
396        // handler relies on (first_child = pane0, then divider, then pane1).
397        let first_pane = Dom::create_div()
398            .with_ids_and_classes(IdOrClassVec::from_const_slice(SPLIT_PANE_FIRST_CLASS))
399            .with_css_props(pane_style(ratio))
400            .with_children(vec![self.first].into());
401
402        let divider = Dom::create_div()
403            .with_ids_and_classes(IdOrClassVec::from_const_slice(SPLIT_PANE_DIVIDER_CLASS))
404            .with_css_props(divider_style(direction));
405
406        let second_pane = Dom::create_div()
407            .with_ids_and_classes(IdOrClassVec::from_const_slice(SPLIT_PANE_SECOND_CLASS))
408            .with_css_props(pane_style(1.0 - ratio))
409            .with_children(vec![self.second].into());
410
411        Dom::create_div()
412            .with_ids_and_classes(IdOrClassVec::from_const_slice(SPLIT_PANE_CLASS))
413            .with_css_props(self.container_style)
414            .with_callbacks(callbacks.into())
415            .with_tab_index(TabIndex::Auto)
416            .with_children(vec![first_pane, divider, second_pane].into())
417    }
418}
419
420impl Default for SplitPane {
421    fn default() -> Self {
422        Self::create(
423            SplitDirection::Horizontal,
424            Dom::create_div(),
425            Dom::create_div(),
426        )
427    }
428}
429
430/// Pointer down → if the press lands near the divider, begin a drag and record
431/// the anchor (cursor position + ratio at this moment). A press elsewhere is left
432/// alone so it can reach the pane content.
433extern "C" fn on_split_pointer_down(mut data: RefAny, info: CallbackInfo) -> Update {
434    let Some(pos) = info.get_cursor_relative_to_node().into_option() else {
435        return Update::DoNothing;
436    };
437    let size = match info.get_hit_node_rect() {
438        Some(r) => r.size,
439        None => return Update::DoNothing,
440    };
441    let Some(mut sp) = data.downcast_mut::<SplitPaneStateWrapper>() else {
442        return Update::DoNothing;
443    };
444    let dir = sp.inner.direction;
445    let msize = main_size(dir, size);
446    if msize <= 0.0 {
447        return Update::DoNothing;
448    }
449    let main = main_axis(dir, pos);
450    let divider_center = sp.inner.ratio * msize;
451    if (main - divider_center).abs() <= GRAB_THRESHOLD {
452        sp.is_dragging = true;
453        sp.drag_start_px = main;
454        sp.ratio_at_drag_start = sp.inner.ratio;
455    }
456    Update::DoNothing
457}
458
459/// Pointer move → while dragging, recompute the ratio from the cursor delta and
460/// live-resize the two panes' `flex-grow`, then fire the user's `on_resize`.
461extern "C" fn on_split_pointer_move(mut data: RefAny, mut info: CallbackInfo) -> Update {
462    let Some(mut sp) = data.downcast_mut::<SplitPaneStateWrapper>() else {
463        return Update::DoNothing;
464    };
465    if !sp.is_dragging {
466        return Update::DoNothing;
467    }
468    let dir = sp.inner.direction;
469    let Some(pos) = info.get_cursor_relative_to_node().into_option() else {
470        return Update::DoNothing;
471    };
472    let size = match info.get_hit_node_rect() {
473        Some(r) => r.size,
474        None => return Update::DoNothing,
475    };
476    let msize = main_size(dir, size);
477    if msize <= 0.0 {
478        return Update::DoNothing;
479    }
480    let main = main_axis(dir, pos);
481    let delta = main - sp.drag_start_px;
482    let new_ratio = (sp.ratio_at_drag_start + delta / msize).clamp(MIN_RATIO, MAX_RATIO);
483    sp.inner.ratio = new_ratio;
484
485    // Resize the two panes. Children are [pane0, divider, pane1]; the callback
486    // node (hit node) is the container.
487    let container = info.get_hit_node();
488    if let Some(pane0) = info.get_first_child(container) {
489        info.set_css_property(pane0, flex_grow_prop(new_ratio));
490        if let Some(divider) = info.get_next_sibling(pane0) {
491            if let Some(pane1) = info.get_next_sibling(divider) {
492                info.set_css_property(pane1, flex_grow_prop(1.0 - new_ratio));
493            }
494        }
495    }
496
497    let inner = sp.inner;
498    match sp.on_resize.as_mut() {
499        Some(SplitPaneOnResize { callback, refany }) => (callback.cb)(refany.clone(), info, inner),
500        None => Update::DoNothing,
501    }
502}
503
504/// Pointer up / leave → end the drag.
505extern "C" fn on_split_pointer_up(mut data: RefAny, _info: CallbackInfo) -> Update {
506    if let Some(mut sp) = data.downcast_mut::<SplitPaneStateWrapper>() {
507        sp.is_dragging = false;
508    }
509    Update::DoNothing
510}
511
512impl From<SplitPane> for Dom {
513    fn from(s: SplitPane) -> Self {
514        s.dom()
515    }
516}
517
518#[cfg(test)]
519#[allow(
520    clippy::float_cmp,
521    clippy::too_many_lines,
522    clippy::cast_precision_loss,
523    clippy::unreadable_literal
524)]
525mod autotest_generated {
526    use std::{
527        collections::{BTreeMap, HashMap},
528        string::{String, ToString},
529        sync::{Arc, Mutex},
530    };
531
532    use azul_core::{
533        dom::{
534            DomId, DomNodeId, EventFilter, FormattingContext, HoverEventFilter, NodeId,
535        },
536        geom::{LogicalPosition, LogicalRect, OptionLogicalPosition},
537        gl::OptionGlContextPtr,
538        hit_test::ScrollPosition,
539        refany::OptionRefAny,
540        resources::RendererResources,
541        styled_dom::{NodeHierarchyItemId, StyledDom},
542        window::{MonitorVec, RawWindowHandle},
543    };
544    use azul_css::{props::basic::length::SizeMetric, system::SystemStyle};
545    use rust_fontconfig::FcFontCache;
546
547    use super::*;
548    #[cfg(feature = "icu")]
549    use crate::icu::IcuLocalizerHandle;
550    use crate::{
551        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
552        solver3::{
553            display_list::DisplayList,
554            geometry::PackedBoxProps,
555            layout_tree::{LayoutNodeHot, LayoutTree},
556        },
557        window::{DomLayoutResult, LayoutWindow},
558        window_state::FullWindowState,
559    };
560
561    // ==================================================================
562    // Fixtures / probes
563    // ==================================================================
564
565    const BOTH_DIRECTIONS: [SplitDirection; 2] =
566        [SplitDirection::Horizontal, SplitDirection::Vertical];
567
568    /// Ratios that survive the `[MIN_RATIO, MAX_RATIO]` clamp untouched *and*
569    /// round-trip exactly through `FloatValue`'s ×1000 fixed-point encoding.
570    const IN_RANGE_RATIOS: [f32; 7] = [0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95];
571
572    fn size(width: f32, height: f32) -> LogicalSize {
573        LogicalSize::new(width, height)
574    }
575
576    fn cursor(x: f32, y: f32) -> OptionLogicalPosition {
577        OptionLogicalPosition::Some(LogicalPosition::new(x, y))
578    }
579
580    fn pos(x: f32, y: f32) -> CursorNodePosition {
581        CursorNodePosition::new(x, y)
582    }
583
584    fn pane(first: Dom, second: Dom) -> SplitPane {
585        SplitPane::create(SplitDirection::Horizontal, first, second)
586    }
587
588    fn plain(direction: SplitDirection) -> SplitPane {
589        SplitPane::create(direction, Dom::create_div(), Dom::create_div())
590    }
591
592    fn div_with_class(class: &str) -> Dom {
593        Dom::create_div().with_ids_and_classes(vec![Class(AzString::from(class))].into())
594    }
595
596    /// The declared properties of a style vec, in declaration order.
597    fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
598        v.as_ref().iter().map(|p| p.property.clone()).collect()
599    }
600
601    /// The *kind* of every declared property, in order (values ignored).
602    fn kinds(v: &CssPropertyWithConditionsVec) -> Vec<core::mem::Discriminant<CssProperty>> {
603        v.as_ref()
604            .iter()
605            .map(|p| core::mem::discriminant(&p.property))
606            .collect()
607    }
608
609    fn find<T>(
610        v: &CssPropertyWithConditionsVec,
611        f: impl Fn(&CssProperty) -> Option<T>,
612    ) -> Option<T> {
613        v.as_ref().iter().find_map(|p| f(&p.property))
614    }
615
616    fn flex_grow_of(p: &CssProperty) -> Option<f32> {
617        match p {
618            CssProperty::FlexGrow(g) => g.get_property().map(|g| g.inner.get()),
619            _ => None,
620        }
621    }
622
623    /// The raw ×1000 fixed-point encoding behind a `flex-grow` declaration —
624    /// the thing that actually saturates, not the `f32` it decodes back to.
625    fn flex_grow_raw(p: &CssProperty) -> Option<isize> {
626        match p {
627            CssProperty::FlexGrow(g) => g.get_property().map(|g| g.inner.number()),
628            _ => None,
629        }
630    }
631
632    fn grow(v: &CssPropertyWithConditionsVec) -> Option<f32> {
633        find(v, flex_grow_of)
634    }
635
636    fn shrink(v: &CssPropertyWithConditionsVec) -> Option<f32> {
637        find(v, |p| match p {
638            CssProperty::FlexShrink(s) => s.get_property().map(|s| s.inner.get()),
639            _ => None,
640        })
641    }
642
643    fn width_pv(v: &CssPropertyWithConditionsVec) -> Option<PixelValue> {
644        find(v, |p| match p {
645            CssProperty::Width(w) => match w.get_property() {
646                Some(LayoutWidth::Px(pv)) => Some(*pv),
647                _ => None,
648            },
649            _ => None,
650        })
651    }
652
653    fn height_pv(v: &CssPropertyWithConditionsVec) -> Option<PixelValue> {
654        find(v, |p| match p {
655            CssProperty::Height(h) => match h.get_property() {
656                Some(LayoutHeight::Px(pv)) => Some(*pv),
657                _ => None,
658            },
659            _ => None,
660        })
661    }
662
663    fn flex_basis_pv(v: &CssPropertyWithConditionsVec) -> Option<PixelValue> {
664        find(v, |p| match p {
665            CssProperty::FlexBasis(b) => match b.get_property() {
666                Some(LayoutFlexBasis::Exact(pv)) => Some(*pv),
667                _ => None,
668            },
669            _ => None,
670        })
671    }
672
673    fn min_width_pv(v: &CssPropertyWithConditionsVec) -> Option<PixelValue> {
674        find(v, |p| match p {
675            CssProperty::MinWidth(w) => w.get_property().map(|w| w.inner),
676            _ => None,
677        })
678    }
679
680    fn min_height_pv(v: &CssPropertyWithConditionsVec) -> Option<PixelValue> {
681        find(v, |p| match p {
682            CssProperty::MinHeight(h) => h.get_property().map(|h| h.inner),
683            _ => None,
684        })
685    }
686
687    fn display(v: &CssPropertyWithConditionsVec) -> Option<LayoutDisplay> {
688        find(v, |p| match p {
689            CssProperty::Display(d) => d.get_property().copied(),
690            _ => None,
691        })
692    }
693
694    fn flex_direction(v: &CssPropertyWithConditionsVec) -> Option<LayoutFlexDirection> {
695        find(v, |p| match p {
696            CssProperty::FlexDirection(d) => d.get_property().copied(),
697            _ => None,
698        })
699    }
700
701    fn cursor_style(v: &CssPropertyWithConditionsVec) -> Option<StyleCursor> {
702        find(v, |p| match p {
703            CssProperty::Cursor(c) => c.get_property().copied(),
704            _ => None,
705        })
706    }
707
708    fn background_color(v: &CssPropertyWithConditionsVec) -> Option<ColorU> {
709        v.as_ref().iter().find_map(|p| match &p.property {
710            CssProperty::BackgroundContent(b) => match b.get_property()?.as_ref().first()? {
711                StyleBackgroundContent::Color(c) => Some(*c),
712                _ => None,
713            },
714            _ => None,
715        })
716    }
717
718    /// `(overflow-x, overflow-y)` declarations, if any.
719    fn overflows(v: &CssPropertyWithConditionsVec) -> (Option<LayoutOverflow>, Option<LayoutOverflow>) {
720        (
721            find(v, |p| match p {
722                CssProperty::OverflowX(o) => o.get_property().copied(),
723                _ => None,
724            }),
725            find(v, |p| match p {
726                CssProperty::OverflowY(o) => o.get_property().copied(),
727                _ => None,
728            }),
729        )
730    }
731
732    /// The `f32` of a `PixelValue`, asserting it is an absolute `px` length —
733    /// an `em`/`%` slipping into the divider thickness or the pane's zero basis
734    /// would resolve against the parent font/box instead of being fixed.
735    fn px(pv: PixelValue) -> f32 {
736        assert_eq!(pv.metric, SizeMetric::Px, "expected an absolute px length");
737        pv.number.get()
738    }
739
740    fn percent(pv: PixelValue) -> f32 {
741        assert_eq!(pv.metric, SizeMetric::Percent, "expected a percentage");
742        pv.number.get()
743    }
744
745    // ---- DOM probes ----
746
747    fn dom_classes(d: &Dom) -> Vec<String> {
748        d.root
749            .get_ids_and_classes()
750            .as_ref()
751            .iter()
752            .filter_map(|c| match c {
753                IdOrClass::Class(s) => Some(s.as_str().to_string()),
754                IdOrClass::Id(_) => None,
755            })
756            .collect()
757    }
758
759    /// The inline (`with_css_props`) declarations of a rendered node, in order.
760    fn inline_properties(d: &Dom) -> Vec<CssProperty> {
761        d.root
762            .style
763            .iter_inline_properties()
764            .map(|(p, _)| p.clone())
765            .collect()
766    }
767
768    fn inline_grow(d: &Dom) -> Option<f32> {
769        inline_properties(d).iter().find_map(flex_grow_of)
770    }
771
772    fn child(d: &Dom, idx: usize) -> &Dom {
773        &d.children.as_ref()[idx]
774    }
775
776    // ---- callback harness ----
777
778    fn node(idx: usize) -> DomNodeId {
779        DomNodeId {
780            dom: DomId::ROOT_ID,
781            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
782        }
783    }
784
785    fn node_none() -> DomNodeId {
786        DomNodeId {
787            dom: DomId::ROOT_ID,
788            node: NodeHierarchyItemId::NONE,
789        }
790    }
791
792    fn empty_layout_tree() -> LayoutTree {
793        LayoutTree {
794            nodes: Vec::new(),
795            warm: Vec::new(),
796            cold: Vec::new(),
797            root: 0,
798            dom_to_layout: BTreeMap::new(),
799            children_arena: Vec::new(),
800            children_offsets: Vec::new(),
801            subtree_needs_intrinsic: Vec::new(),
802        }
803    }
804
805    /// A `DomLayoutResult` over `styled_dom` in which every `(dom node, size)`
806    /// pair in `boxes` has a real laid-out box at the origin — enough for
807    /// `get_hit_node_rect()` to report a size. An empty `boxes` models the
808    /// "callback fired before the first layout" case (no rect at all).
809    fn layout_result(styled_dom: StyledDom, boxes: &[(usize, LogicalSize)]) -> DomLayoutResult {
810        let mut lr = DomLayoutResult {
811            styled_dom,
812            layout_tree: empty_layout_tree(),
813            calculated_positions: Vec::new(),
814            viewport: LogicalRect::zero(),
815            display_list: DisplayList::default(),
816            scroll_ids: HashMap::new(),
817            scroll_id_to_node_id: HashMap::new(),
818        };
819        for (layout_index, (node_index, used)) in boxes.iter().enumerate() {
820            lr.layout_tree
821                .dom_to_layout
822                .insert(NodeId::new(*node_index), vec![layout_index]);
823            lr.layout_tree.nodes.push(LayoutNodeHot {
824                box_props: PackedBoxProps::default(),
825                dom_node_id: Some(NodeId::new(*node_index)),
826                used_size: Some(*used),
827                formatting_context: FormattingContext::Flex,
828                parent: None,
829            });
830            lr.calculated_positions.push(LogicalPosition::zero());
831        }
832        lr
833    }
834
835    /// Runs `f` against a real `CallbackInfo` over `styled_dom`, with `hit` as
836    /// the hit node and `cur` as the node-relative cursor. Returns `f`'s value
837    /// plus every change the callback pushed onto the transaction log.
838    fn drive<R>(
839        styled_dom: StyledDom,
840        boxes: &[(usize, LogicalSize)],
841        hit: DomNodeId,
842        cur: OptionLogicalPosition,
843        f: impl FnOnce(CallbackInfo) -> R,
844    ) -> (R, Vec<CallbackChange>) {
845        let mut layout_window =
846            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
847        layout_window
848            .layout_results
849            .insert(DomId::ROOT_ID, layout_result(styled_dom, boxes));
850
851        let renderer_resources = RendererResources::default();
852        let previous_window_state: Option<FullWindowState> = None;
853        let current_window_state = FullWindowState::default();
854        let gl_context = OptionGlContextPtr::None;
855        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
856            BTreeMap::new();
857        let window_handle = RawWindowHandle::Unsupported;
858        let system_callbacks = ExternalSystemCallbacks::rust_internal();
859
860        let ref_data = CallbackInfoRefData {
861            layout_window: &layout_window,
862            renderer_resources: &renderer_resources,
863            previous_window_state: &previous_window_state,
864            current_window_state: &current_window_state,
865            gl_context: &gl_context,
866            current_scroll_manager: &scroll_states,
867            current_window_handle: &window_handle,
868            system_callbacks: &system_callbacks,
869            system_style: Arc::new(SystemStyle::default()),
870            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
871            #[cfg(feature = "icu")]
872            icu_localizer: IcuLocalizerHandle::default(),
873            ctx: OptionRefAny::None,
874        };
875
876        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
877        let info = CallbackInfo::new(
878            &ref_data,
879            &changes,
880            hit,
881            cur,
882            OptionLogicalPosition::None,
883        );
884
885        let out = f(info);
886        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
887        (out, recorded)
888    }
889
890    /// Renders `sp` and hands back both the styled DOM *and* the very `RefAny`
891    /// the widget registered on its own pointer callbacks. Driving the handlers
892    /// with these two is the real wiring — nothing is rebuilt by hand, so a
893    /// mismatch between what `dom()` stores and what the handlers expect cannot
894    /// hide behind the fixture.
895    fn laid_out(sp: SplitPane) -> (StyledDom, RefAny) {
896        let dom = sp.dom();
897        let state = dom.root.callbacks.as_ref()[0].refany.clone();
898        (StyledDom::create_from_dom(dom), state)
899    }
900
901    fn wrapper(state: &mut RefAny) -> SplitPaneStateWrapper {
902        let guard = state
903            .downcast_ref::<SplitPaneStateWrapper>()
904            .expect("the widget state changed type");
905        (*guard).clone()
906    }
907
908    /// The `(node, flex-grow)` pairs a callback wrote through `set_css_property`.
909    fn css_changes(changes: &[CallbackChange]) -> Vec<(NodeId, f32)> {
910        changes
911            .iter()
912            .filter_map(|c| match c {
913                CallbackChange::ChangeNodeCssProperties {
914                    node_id, properties, ..
915                } => properties
916                    .as_ref()
917                    .first()
918                    .and_then(flex_grow_of)
919                    .map(|g| (*node_id, g)),
920                _ => None,
921            })
922            .collect()
923    }
924
925    /// Payload for the user's `on_resize` hook: every state it was handed.
926    #[derive(Debug, Default)]
927    struct ResizeLog {
928        seen: Vec<SplitPaneState>,
929    }
930
931    extern "C" fn record_resize(
932        mut data: RefAny,
933        _info: CallbackInfo,
934        state: SplitPaneState,
935    ) -> Update {
936        if let Some(mut log) = data.downcast_mut::<ResizeLog>() {
937            log.seen.push(state);
938        }
939        Update::RefreshDom
940    }
941
942    extern "C" fn resize_do_nothing(
943        _data: RefAny,
944        _info: CallbackInfo,
945        _state: SplitPaneState,
946    ) -> Update {
947        Update::DoNothing
948    }
949
950    fn logged(log: &mut RefAny) -> Vec<SplitPaneState> {
951        let guard = log.downcast_ref::<ResizeLog>().expect("payload type");
952        guard.seen.clone()
953    }
954
955    // ==================================================================
956    // flex_grow_prop  (numeric)
957    // ==================================================================
958
959    #[test]
960    fn flex_grow_prop_round_trips_the_representative_ratios() {
961        for v in IN_RANGE_RATIOS {
962            let got = flex_grow_of(&flex_grow_prop(v)).expect("flex-grow declaration");
963            assert!((got - v).abs() < 1e-6, "flex-grow({v}) decoded as {got}");
964        }
965    }
966
967    #[test]
968    fn flex_grow_prop_zero_is_exactly_zero() {
969        assert_eq!(flex_grow_raw(&flex_grow_prop(0.0)), Some(0));
970        assert_eq!(flex_grow_of(&flex_grow_prop(0.0)), Some(0.0));
971        // -0.0 encodes to the same slot (no negative-zero isize).
972        assert_eq!(flex_grow_raw(&flex_grow_prop(-0.0)), Some(0));
973    }
974
975    #[test]
976    fn flex_grow_prop_quantizes_to_three_decimals_by_truncation() {
977        // FloatValue stores value * 1000 truncated to an isize.
978        assert_eq!(flex_grow_raw(&flex_grow_prop(1.0 / 3.0)), Some(333));
979        assert_eq!(flex_grow_raw(&flex_grow_prop(2.0 / 3.0)), Some(666));
980        // Sub-precision ratios are indistinguishable from a fully collapsed
981        // pane: anything under 0.001 encodes as flex-grow: 0.
982        assert_eq!(flex_grow_raw(&flex_grow_prop(0.0009)), Some(0));
983        assert_eq!(flex_grow_of(&flex_grow_prop(0.0009)), Some(0.0));
984    }
985
986    #[test]
987    fn flex_grow_prop_nan_silently_becomes_zero_not_a_panic() {
988        // `NaN as isize` saturates to 0, so a NaN ratio does not panic — it
989        // renders as `flex-grow: 0`, i.e. a fully collapsed pane.
990        assert_eq!(flex_grow_raw(&flex_grow_prop(f32::NAN)), Some(0));
991        assert_eq!(flex_grow_of(&flex_grow_prop(f32::NAN)), Some(0.0));
992    }
993
994    #[test]
995    fn flex_grow_prop_infinities_saturate_to_the_isize_bounds() {
996        assert_eq!(flex_grow_raw(&flex_grow_prop(f32::INFINITY)), Some(isize::MAX));
997        assert_eq!(
998            flex_grow_raw(&flex_grow_prop(f32::NEG_INFINITY)),
999            Some(isize::MIN)
1000        );
1001        // ...and decode back to a finite (huge) number, never inf/NaN.
1002        for v in [f32::INFINITY, f32::NEG_INFINITY] {
1003            let got = flex_grow_of(&flex_grow_prop(v)).expect("flex-grow declaration");
1004            assert!(got.is_finite(), "flex-grow({v}) decoded as {got}");
1005        }
1006    }
1007
1008    #[test]
1009    fn flex_grow_prop_f32_extremes_do_not_panic() {
1010        for v in [
1011            f32::MAX,
1012            f32::MIN,
1013            f32::MIN_POSITIVE,
1014            -f32::MIN_POSITIVE,
1015            1.0e30,
1016            -1.0e30,
1017            f32::EPSILON,
1018        ] {
1019            let got = flex_grow_of(&flex_grow_prop(v)).expect("flex-grow declaration");
1020            assert!(!got.is_nan(), "flex-grow({v}) decoded as NaN");
1021        }
1022        // f32::MAX * 1000 overflows to +inf before the cast, so it saturates
1023        // exactly like +inf does (no wrap, no debug panic).
1024        assert_eq!(flex_grow_raw(&flex_grow_prop(f32::MAX)), Some(isize::MAX));
1025        assert_eq!(flex_grow_raw(&flex_grow_prop(f32::MIN)), Some(isize::MIN));
1026    }
1027
1028    #[test]
1029    fn flex_grow_prop_passes_negative_values_through_unclamped() {
1030        // The primitive does not clamp: clamping is `set_ratio`'s job.
1031        assert_eq!(flex_grow_raw(&flex_grow_prop(-1.0)), Some(-1000));
1032        assert_eq!(flex_grow_of(&flex_grow_prop(-1.0)), Some(-1.0));
1033        assert_eq!(flex_grow_of(&flex_grow_prop(-0.5)), Some(-0.5));
1034    }
1035
1036    #[test]
1037    fn flex_grow_prop_is_deterministic() {
1038        for v in [0.0, 0.5, -1.0, f32::NAN, f32::INFINITY] {
1039            assert_eq!(
1040                flex_grow_raw(&flex_grow_prop(v)),
1041                flex_grow_raw(&flex_grow_prop(v))
1042            );
1043        }
1044    }
1045
1046    // ==================================================================
1047    // main_axis  (other)
1048    // ==================================================================
1049
1050    #[test]
1051    fn main_axis_picks_x_for_horizontal_and_y_for_vertical() {
1052        // Distinct components: an x/y swap cannot hide behind a square input.
1053        let p = pos(3.0, 7.0);
1054        assert_eq!(main_axis(SplitDirection::Horizontal, p), 3.0);
1055        assert_eq!(main_axis(SplitDirection::Vertical, p), 7.0);
1056    }
1057
1058    #[test]
1059    fn main_axis_propagates_nan_and_infinities_verbatim() {
1060        assert!(main_axis(SplitDirection::Horizontal, pos(f32::NAN, 0.0)).is_nan());
1061        assert!(main_axis(SplitDirection::Vertical, pos(0.0, f32::NAN)).is_nan());
1062        assert_eq!(
1063            main_axis(SplitDirection::Horizontal, pos(f32::INFINITY, 0.0)),
1064            f32::INFINITY
1065        );
1066        assert_eq!(
1067            main_axis(SplitDirection::Vertical, pos(0.0, f32::NEG_INFINITY)),
1068            f32::NEG_INFINITY
1069        );
1070        // The *other* axis being NaN must not leak into the selected one.
1071        assert_eq!(main_axis(SplitDirection::Horizontal, pos(5.0, f32::NAN)), 5.0);
1072        assert_eq!(main_axis(SplitDirection::Vertical, pos(f32::NAN, 5.0)), 5.0);
1073    }
1074
1075    #[test]
1076    fn main_axis_extreme_finite_values_are_exact() {
1077        for v in [0.0, -0.0, f32::MAX, f32::MIN, 1.0e30, -1.0e30, f32::EPSILON] {
1078            assert_eq!(main_axis(SplitDirection::Horizontal, pos(v, 1.0)), v);
1079            assert_eq!(main_axis(SplitDirection::Vertical, pos(1.0, v)), v);
1080        }
1081    }
1082
1083    // ==================================================================
1084    // main_size  (numeric)
1085    // ==================================================================
1086
1087    #[test]
1088    fn main_size_picks_width_for_horizontal_and_height_for_vertical() {
1089        let s = size(11.0, 22.0);
1090        assert_eq!(main_size(SplitDirection::Horizontal, s), 11.0);
1091        assert_eq!(main_size(SplitDirection::Vertical, s), 22.0);
1092    }
1093
1094    #[test]
1095    fn main_size_zero_and_negative_pass_straight_through() {
1096        // The callers - not this helper - reject non-positive sizes.
1097        assert_eq!(main_size(SplitDirection::Horizontal, size(0.0, 5.0)), 0.0);
1098        assert_eq!(main_size(SplitDirection::Vertical, size(5.0, 0.0)), 0.0);
1099        assert_eq!(main_size(SplitDirection::Horizontal, size(-40.0, 5.0)), -40.0);
1100        assert_eq!(main_size(SplitDirection::Vertical, size(5.0, -40.0)), -40.0);
1101    }
1102
1103    #[test]
1104    fn main_size_nan_and_infinities_do_not_panic() {
1105        assert!(main_size(SplitDirection::Horizontal, size(f32::NAN, 1.0)).is_nan());
1106        assert!(main_size(SplitDirection::Vertical, size(1.0, f32::NAN)).is_nan());
1107        assert_eq!(
1108            main_size(SplitDirection::Horizontal, size(f32::INFINITY, 1.0)),
1109            f32::INFINITY
1110        );
1111        assert_eq!(
1112            main_size(SplitDirection::Vertical, size(1.0, f32::NEG_INFINITY)),
1113            f32::NEG_INFINITY
1114        );
1115    }
1116
1117    #[test]
1118    fn main_size_nan_slips_past_the_non_positive_guard_the_callers_use() {
1119        // Both pointer handlers gate on `msize <= 0.0`. NaN fails that
1120        // comparison, so a NaN container size is treated as usable and
1121        // poisons every ratio computed from it (see the pointer_move tests).
1122        let msize = main_size(SplitDirection::Horizontal, size(f32::NAN, 1.0));
1123        // Spelled out as a binding so the assertion is `!caught`, not a negated
1124        // partial-ord comparison — the guard below is verbatim what the handlers use.
1125        let caught_by_the_guard = msize <= 0.0;
1126        assert!(
1127            !caught_by_the_guard,
1128            "NaN must not be caught by the <= 0 guard"
1129        );
1130    }
1131
1132    #[test]
1133    fn main_size_extreme_finite_values_are_exact() {
1134        for v in [f32::MAX, f32::MIN, f32::MIN_POSITIVE, 1.0e30, -1.0e30] {
1135            assert_eq!(main_size(SplitDirection::Horizontal, size(v, 1.0)), v);
1136            assert_eq!(main_size(SplitDirection::Vertical, size(1.0, v)), v);
1137        }
1138    }
1139
1140    // ==================================================================
1141    // container_style  (other)
1142    // ==================================================================
1143
1144    #[test]
1145    fn container_style_declares_a_full_size_flex_box() {
1146        for dir in BOTH_DIRECTIONS {
1147            let s = container_style(dir);
1148            assert_eq!(properties(&s).len(), 7, "{dir:?}");
1149            assert_eq!(display(&s), Some(LayoutDisplay::Flex), "{dir:?}");
1150            assert_eq!(grow(&s), Some(1.0), "{dir:?}");
1151            assert_eq!(percent(width_pv(&s).expect("width")), 100.0, "{dir:?}");
1152            assert_eq!(percent(height_pv(&s).expect("height")), 100.0, "{dir:?}");
1153            assert_eq!(
1154                overflows(&s),
1155                (Some(LayoutOverflow::Hidden), Some(LayoutOverflow::Hidden)),
1156                "{dir:?}"
1157            );
1158        }
1159    }
1160
1161    #[test]
1162    fn container_style_direction_only_changes_the_flex_direction() {
1163        let h = container_style(SplitDirection::Horizontal);
1164        let v = container_style(SplitDirection::Vertical);
1165        assert_eq!(
1166            flex_direction(&h),
1167            Some(LayoutFlexDirection::Row),
1168            "horizontal splits lay the panes out left/right"
1169        );
1170        assert_eq!(
1171            flex_direction(&v),
1172            Some(LayoutFlexDirection::Column),
1173            "vertical splits lay the panes out top/bottom"
1174        );
1175        // Same declarations, same order — only the direction value differs.
1176        assert_eq!(kinds(&h), kinds(&v));
1177        let (ph, pv) = (properties(&h), properties(&v));
1178        let differing = ph
1179            .iter()
1180            .zip(pv.iter())
1181            .filter(|(a, b)| a != b)
1182            .count();
1183        assert_eq!(differing, 1, "exactly one declaration may depend on the axis");
1184    }
1185
1186    #[test]
1187    fn container_style_is_deterministic() {
1188        for dir in BOTH_DIRECTIONS {
1189            assert_eq!(properties(&container_style(dir)), properties(&container_style(dir)));
1190        }
1191    }
1192
1193    // ==================================================================
1194    // pane_style  (numeric)
1195    // ==================================================================
1196
1197    #[test]
1198    fn pane_style_declares_the_same_six_properties_for_every_grow() {
1199        let reference = kinds(&pane_style(0.5));
1200        assert_eq!(reference.len(), 6);
1201        for g in [
1202            0.0,
1203            1.0,
1204            -1.0,
1205            f32::NAN,
1206            f32::INFINITY,
1207            f32::NEG_INFINITY,
1208            f32::MAX,
1209            f32::MIN,
1210            1.0e30,
1211        ] {
1212            assert_eq!(kinds(&pane_style(g)), reference, "grow = {g}");
1213        }
1214    }
1215
1216    #[test]
1217    fn pane_style_basis_and_minimums_are_zero_px() {
1218        let s = pane_style(0.5);
1219        // flex-basis: 0 is what makes the grow values a *proportion* of the
1220        // container instead of a share of the leftover space.
1221        assert_eq!(px(flex_basis_pv(&s).expect("flex-basis")), 0.0);
1222        assert_eq!(px(min_width_pv(&s).expect("min-width")), 0.0);
1223        assert_eq!(px(min_height_pv(&s).expect("min-height")), 0.0);
1224        assert_eq!(
1225            overflows(&s),
1226            (Some(LayoutOverflow::Hidden), Some(LayoutOverflow::Hidden))
1227        );
1228    }
1229
1230    #[test]
1231    fn pane_style_carries_the_grow_through_verbatim() {
1232        for g in IN_RANGE_RATIOS {
1233            let got = grow(&pane_style(g)).expect("flex-grow");
1234            assert!((got - g).abs() < 1e-6, "pane_style({g}) declared {got}");
1235        }
1236        assert_eq!(grow(&pane_style(0.0)), Some(0.0));
1237        assert_eq!(grow(&pane_style(1.0)), Some(1.0));
1238    }
1239
1240    #[test]
1241    fn pane_style_complementary_grows_sum_to_one() {
1242        // `FloatValue` truncates at 1/1000, so each pane can lose up to 0.001:
1243        // the pair must still add up to the whole container.
1244        for r in IN_RANGE_RATIOS {
1245            let a = grow(&pane_style(r)).expect("first pane grow");
1246            let b = grow(&pane_style(1.0 - r)).expect("second pane grow");
1247            assert!(
1248                (a + b - 1.0).abs() < 3e-3,
1249                "ratio {r}: {a} + {b} does not fill the container"
1250            );
1251        }
1252    }
1253
1254    #[test]
1255    fn pane_style_nan_grow_collapses_the_pane_instead_of_panicking() {
1256        // A NaN ratio produces NaN for *both* panes (1.0 - NaN is NaN), and
1257        // both encode as flex-grow: 0 — the split collapses to nothing rather
1258        // than crashing.
1259        assert_eq!(grow(&pane_style(f32::NAN)), Some(0.0));
1260        assert_eq!(grow(&pane_style(1.0 - f32::NAN)), Some(0.0));
1261    }
1262
1263    #[test]
1264    fn pane_style_extreme_grows_do_not_panic() {
1265        for g in [f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, -1.0e30] {
1266            let got = grow(&pane_style(g)).expect("flex-grow");
1267            assert!(got.is_finite(), "pane_style({g}) declared {got}");
1268        }
1269    }
1270
1271    // ==================================================================
1272    // divider_style  (other)
1273    // ==================================================================
1274
1275    #[test]
1276    fn divider_style_horizontal_is_a_fixed_width_col_resize_bar() {
1277        let s = divider_style(SplitDirection::Horizontal);
1278        assert_eq!(px(width_pv(&s).expect("width")), DIVIDER_THICKNESS as f32);
1279        assert_eq!(cursor_style(&s), Some(StyleCursor::ColResize));
1280        // The cross axis is deliberately left to the flex default (stretch).
1281        assert!(height_pv(&s).is_none(), "vertical size must stay unset");
1282    }
1283
1284    #[test]
1285    fn divider_style_vertical_is_a_fixed_height_row_resize_bar() {
1286        let s = divider_style(SplitDirection::Vertical);
1287        assert_eq!(px(height_pv(&s).expect("height")), DIVIDER_THICKNESS as f32);
1288        assert_eq!(cursor_style(&s), Some(StyleCursor::RowResize));
1289        assert!(width_pv(&s).is_none(), "horizontal size must stay unset");
1290    }
1291
1292    #[test]
1293    fn divider_style_never_grows_or_shrinks_and_is_visible() {
1294        for dir in BOTH_DIRECTIONS {
1295            let s = divider_style(dir);
1296            assert_eq!(properties(&s).len(), 5, "{dir:?}");
1297            // A grow/shrink of anything but 0 would let the divider eat the
1298            // panes' space and silently change the split ratio.
1299            assert_eq!(grow(&s), Some(0.0), "{dir:?}");
1300            assert_eq!(shrink(&s), Some(0.0), "{dir:?}");
1301            assert_eq!(background_color(&s), Some(DIVIDER_COLOR), "{dir:?}");
1302        }
1303    }
1304
1305    #[test]
1306    fn divider_style_axes_never_agree() {
1307        let h = divider_style(SplitDirection::Horizontal);
1308        let v = divider_style(SplitDirection::Vertical);
1309        assert_ne!(cursor_style(&h), cursor_style(&v));
1310        assert_ne!(properties(&h), properties(&v));
1311    }
1312
1313    // ==================================================================
1314    // SplitPane::create / Default  (constructor)
1315    // ==================================================================
1316
1317    #[test]
1318    fn create_starts_centred_idle_and_hookless() {
1319        for dir in BOTH_DIRECTIONS {
1320            let sp = plain(dir);
1321            assert_eq!(sp.split_pane_state.inner.direction, dir);
1322            assert_eq!(sp.split_pane_state.inner.ratio, 0.5);
1323            assert!(!sp.split_pane_state.is_dragging, "{dir:?}");
1324            assert_eq!(sp.split_pane_state.drag_start_px, 0.0, "{dir:?}");
1325            assert_eq!(sp.split_pane_state.ratio_at_drag_start, 0.0, "{dir:?}");
1326            assert!(sp.split_pane_state.on_resize.is_none(), "{dir:?}");
1327            assert_eq!(
1328                properties(&sp.container_style),
1329                properties(&container_style(dir)),
1330                "{dir:?}"
1331            );
1332        }
1333    }
1334
1335    #[test]
1336    fn default_split_pane_matches_a_horizontal_create() {
1337        let d = SplitPane::default();
1338        let c = plain(SplitDirection::Horizontal);
1339        assert_eq!(d.split_pane_state, c.split_pane_state);
1340        assert_eq!(properties(&d.container_style), properties(&c.container_style));
1341    }
1342
1343    #[test]
1344    fn create_keeps_the_children_in_first_second_order() {
1345        let sp = pane(div_with_class("alpha"), div_with_class("beta"));
1346        assert_eq!(dom_classes(&sp.first), vec!["alpha".to_string()]);
1347        assert_eq!(dom_classes(&sp.second), vec!["beta".to_string()]);
1348    }
1349
1350    // ==================================================================
1351    // set_ratio / with_ratio  (numeric)
1352    // ==================================================================
1353
1354    #[test]
1355    fn set_ratio_keeps_in_range_values_verbatim() {
1356        for r in IN_RANGE_RATIOS {
1357            let mut sp = plain(SplitDirection::Horizontal);
1358            sp.set_ratio(r);
1359            assert_eq!(sp.split_pane_state.inner.ratio, r);
1360        }
1361    }
1362
1363    #[test]
1364    fn set_ratio_clamps_everything_out_of_range_into_min_max() {
1365        let below = [
1366            0.0_f32,
1367            -0.0,
1368            -1.0,
1369            0.049_999,
1370            f32::MIN,
1371            f32::NEG_INFINITY,
1372            -1.0e30,
1373        ];
1374        let above = [1.0_f32, 2.0, 0.950_001, f32::MAX, f32::INFINITY, 1.0e30];
1375        for r in below {
1376            let mut sp = plain(SplitDirection::Horizontal);
1377            sp.set_ratio(r);
1378            assert_eq!(
1379                sp.split_pane_state.inner.ratio, MIN_RATIO,
1380                "{r} must clamp up to MIN_RATIO"
1381            );
1382        }
1383        for r in above {
1384            let mut sp = plain(SplitDirection::Horizontal);
1385            sp.set_ratio(r);
1386            assert_eq!(
1387                sp.split_pane_state.inner.ratio, MAX_RATIO,
1388                "{r} must clamp down to MAX_RATIO"
1389            );
1390        }
1391    }
1392
1393    #[test]
1394    fn set_ratio_nan_escapes_the_documented_clamp() {
1395        // PIN + KNOWN DEFECT: `f32::clamp` returns NaN for a NaN input, so a
1396        // NaN ratio is stored verbatim even though the doc comment promises
1397        // `[MIN_RATIO, MAX_RATIO]`. Downstream it renders as flex-grow: 0 on
1398        // BOTH panes (see `dom_with_a_nan_ratio_collapses_both_panes`), i.e. an
1399        // empty split pane rather than a panic. Flip this test loudly if the
1400        // clamp is ever made NaN-safe.
1401        let mut sp = plain(SplitDirection::Horizontal);
1402        sp.set_ratio(f32::NAN);
1403        assert!(
1404            sp.split_pane_state.inner.ratio.is_nan(),
1405            "NaN is currently stored as-is"
1406        );
1407    }
1408
1409    #[test]
1410    fn set_ratio_is_idempotent_and_a_projection() {
1411        for r in [-5.0_f32, 0.0, 0.3, 0.5, 0.95, 7.0, f32::INFINITY] {
1412            let mut once = plain(SplitDirection::Horizontal);
1413            once.set_ratio(r);
1414            let settled = once.split_pane_state.inner.ratio;
1415            once.set_ratio(settled);
1416            assert_eq!(once.split_pane_state.inner.ratio, settled, "input {r}");
1417            assert!(
1418                (MIN_RATIO..=MAX_RATIO).contains(&settled),
1419                "input {r} settled outside the documented range at {settled}"
1420            );
1421        }
1422    }
1423
1424    #[test]
1425    fn set_ratio_touches_nothing_but_the_ratio() {
1426        let mut sp = plain(SplitDirection::Vertical);
1427        let before = properties(&sp.container_style);
1428        sp.set_ratio(0.2);
1429        assert_eq!(sp.split_pane_state.inner.direction, SplitDirection::Vertical);
1430        assert!(!sp.split_pane_state.is_dragging);
1431        assert_eq!(properties(&sp.container_style), before);
1432    }
1433
1434    #[test]
1435    fn with_ratio_agrees_with_set_ratio_on_every_input() {
1436        for r in [
1437            -1.0_f32,
1438            0.0,
1439            0.05,
1440            0.5,
1441            0.95,
1442            1.0,
1443            f32::MAX,
1444            f32::NEG_INFINITY,
1445        ] {
1446            let built = plain(SplitDirection::Horizontal).with_ratio(r);
1447            let mut mutated = plain(SplitDirection::Horizontal);
1448            mutated.set_ratio(r);
1449            assert_eq!(
1450                built.split_pane_state.inner.ratio,
1451                mutated.split_pane_state.inner.ratio,
1452                "input {r}"
1453            );
1454        }
1455    }
1456
1457    #[test]
1458    fn with_ratio_preserves_the_rest_of_the_widget() {
1459        let sp = SplitPane::create(
1460            SplitDirection::Vertical,
1461            div_with_class("alpha"),
1462            div_with_class("beta"),
1463        )
1464        .with_ratio(0.25);
1465        assert_eq!(sp.split_pane_state.inner.ratio, 0.25);
1466        assert_eq!(sp.split_pane_state.inner.direction, SplitDirection::Vertical);
1467        assert_eq!(dom_classes(&sp.first), vec!["alpha".to_string()]);
1468        assert_eq!(dom_classes(&sp.second), vec!["beta".to_string()]);
1469        assert_eq!(
1470            properties(&sp.container_style),
1471            properties(&container_style(SplitDirection::Vertical))
1472        );
1473    }
1474
1475    // ==================================================================
1476    // set_direction / with_direction  (other / constructor)
1477    // ==================================================================
1478
1479    #[test]
1480    fn set_direction_updates_both_the_state_and_the_container_style() {
1481        let mut sp = plain(SplitDirection::Horizontal);
1482        sp.set_direction(SplitDirection::Vertical);
1483        assert_eq!(sp.split_pane_state.inner.direction, SplitDirection::Vertical);
1484        assert_eq!(
1485            flex_direction(&sp.container_style),
1486            Some(LayoutFlexDirection::Column),
1487            "a stale Row here would lay a vertical split out sideways"
1488        );
1489    }
1490
1491    #[test]
1492    fn set_direction_is_idempotent_and_round_trips() {
1493        let mut sp = plain(SplitDirection::Horizontal);
1494        let original = properties(&sp.container_style);
1495        sp.set_direction(SplitDirection::Vertical);
1496        sp.set_direction(SplitDirection::Vertical);
1497        assert_eq!(
1498            properties(&sp.container_style),
1499            properties(&container_style(SplitDirection::Vertical))
1500        );
1501        sp.set_direction(SplitDirection::Horizontal);
1502        assert_eq!(properties(&sp.container_style), original);
1503    }
1504
1505    #[test]
1506    fn set_direction_discards_a_custom_container_style() {
1507        // PIN: `with_container_style` then `set_direction` silently throws the
1508        // custom style away — the two builders are order-dependent.
1509        let sp = plain(SplitDirection::Horizontal)
1510            .with_container_style(CssPropertyWithConditionsVec::from_vec(vec![]))
1511            .with_direction(SplitDirection::Vertical);
1512        assert_eq!(
1513            properties(&sp.container_style),
1514            properties(&container_style(SplitDirection::Vertical)),
1515            "set_direction overwrites, it does not merge"
1516        );
1517    }
1518
1519    #[test]
1520    fn with_direction_leaves_the_ratio_and_children_alone() {
1521        let sp = SplitPane::create(
1522            SplitDirection::Horizontal,
1523            div_with_class("alpha"),
1524            div_with_class("beta"),
1525        )
1526        .with_ratio(0.3)
1527        .with_direction(SplitDirection::Vertical);
1528        assert_eq!(sp.split_pane_state.inner.ratio, 0.3);
1529        assert_eq!(dom_classes(&sp.first), vec!["alpha".to_string()]);
1530        assert_eq!(dom_classes(&sp.second), vec!["beta".to_string()]);
1531    }
1532
1533    // ==================================================================
1534    // with_container_style  (constructor)
1535    // ==================================================================
1536
1537    #[test]
1538    fn with_container_style_replaces_the_default_verbatim() {
1539        let custom = CssPropertyWithConditionsVec::from_vec(vec![
1540            CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Block)),
1541        ]);
1542        let sp = plain(SplitDirection::Horizontal).with_container_style(custom.clone());
1543        assert_eq!(properties(&sp.container_style), properties(&custom));
1544        assert_eq!(display(&sp.container_style), Some(LayoutDisplay::Block));
1545    }
1546
1547    #[test]
1548    fn with_container_style_accepts_an_empty_vec() {
1549        let sp = plain(SplitDirection::Horizontal)
1550            .with_container_style(CssPropertyWithConditionsVec::from_vec(vec![]));
1551        assert!(properties(&sp.container_style).is_empty());
1552        // The state is untouched, and rendering an unstyled container must not
1553        // panic even though the flex layout is gone.
1554        assert_eq!(sp.split_pane_state.inner.ratio, 0.5);
1555        let dom = sp.dom();
1556        assert_eq!(dom.children.as_ref().len(), 3);
1557    }
1558
1559    // ==================================================================
1560    // swap_with_default  (other)
1561    // ==================================================================
1562
1563    #[test]
1564    fn swap_with_default_returns_the_old_value_and_leaves_a_default() {
1565        let mut sp = SplitPane::create(
1566            SplitDirection::Vertical,
1567            div_with_class("alpha"),
1568            div_with_class("beta"),
1569        )
1570        .with_ratio(0.8);
1571        let old = sp.swap_with_default();
1572
1573        assert_eq!(old.split_pane_state.inner.ratio, 0.8);
1574        assert_eq!(old.split_pane_state.inner.direction, SplitDirection::Vertical);
1575        assert_eq!(dom_classes(&old.first), vec!["alpha".to_string()]);
1576        assert_eq!(dom_classes(&old.second), vec!["beta".to_string()]);
1577
1578        assert_eq!(sp.split_pane_state, SplitPaneStateWrapper::default());
1579        assert!(dom_classes(&sp.first).is_empty());
1580        assert_eq!(
1581            properties(&sp.container_style),
1582            properties(&container_style(SplitDirection::Horizontal))
1583        );
1584    }
1585
1586    #[test]
1587    fn swap_with_default_moves_the_on_resize_hook_out() {
1588        let log = RefAny::new(ResizeLog::default());
1589        let mut sp = plain(SplitDirection::Horizontal)
1590            .with_on_resize(log, record_resize as SplitPaneOnResizeCallbackType);
1591        let old = sp.swap_with_default();
1592        assert!(old.split_pane_state.on_resize.is_some());
1593        assert!(sp.split_pane_state.on_resize.is_none());
1594    }
1595
1596    #[test]
1597    fn swap_with_default_twice_is_stable() {
1598        let mut sp = plain(SplitDirection::Vertical).with_ratio(0.1);
1599        let _first = sp.swap_with_default();
1600        let second = sp.swap_with_default();
1601        assert_eq!(second.split_pane_state, SplitPaneStateWrapper::default());
1602        assert_eq!(sp.split_pane_state, SplitPaneStateWrapper::default());
1603    }
1604
1605    // ==================================================================
1606    // set_on_resize / with_on_resize  (other / constructor)
1607    // ==================================================================
1608
1609    #[test]
1610    fn set_on_resize_installs_the_hook_and_keeps_the_payload() {
1611        let mut log = RefAny::new(ResizeLog::default());
1612        let mut sp = plain(SplitDirection::Horizontal);
1613        sp.set_on_resize(log.clone(), record_resize as SplitPaneOnResizeCallbackType);
1614        let hook = sp.split_pane_state.on_resize.as_ref().expect("hook");
1615        assert_eq!(hook.callback.cb as usize, record_resize as usize);
1616        // The payload is shared, not copied: the widget holds the same data.
1617        assert!(logged(&mut log).is_empty());
1618    }
1619
1620    #[test]
1621    fn set_on_resize_replaces_a_previous_hook() {
1622        let mut sp = plain(SplitDirection::Horizontal);
1623        sp.set_on_resize(
1624            RefAny::new(ResizeLog::default()),
1625            record_resize as SplitPaneOnResizeCallbackType,
1626        );
1627        sp.set_on_resize(
1628            RefAny::new(ResizeLog::default()),
1629            resize_do_nothing as SplitPaneOnResizeCallbackType,
1630        );
1631        let hook = sp.split_pane_state.on_resize.as_ref().expect("hook");
1632        assert_eq!(hook.callback.cb as usize, resize_do_nothing as usize);
1633    }
1634
1635    #[test]
1636    fn with_on_resize_matches_set_on_resize() {
1637        let built = plain(SplitDirection::Horizontal).with_on_resize(
1638            RefAny::new(ResizeLog::default()),
1639            record_resize as SplitPaneOnResizeCallbackType,
1640        );
1641        let mut mutated = plain(SplitDirection::Horizontal);
1642        mutated.set_on_resize(
1643            RefAny::new(ResizeLog::default()),
1644            record_resize as SplitPaneOnResizeCallbackType,
1645        );
1646        assert_eq!(
1647            built.split_pane_state.on_resize.as_ref().map(|h| h.callback.cb as usize),
1648            mutated.split_pane_state.on_resize.as_ref().map(|h| h.callback.cb as usize),
1649        );
1650        // ...and it changes nothing else.
1651        assert_eq!(built.split_pane_state.inner, mutated.split_pane_state.inner);
1652    }
1653
1654    // ==================================================================
1655    // SplitPane::dom  (other)
1656    // ==================================================================
1657
1658    #[test]
1659    fn dom_is_first_pane_divider_second_pane_in_that_order() {
1660        let dom = plain(SplitDirection::Horizontal).dom();
1661        assert_eq!(dom_classes(&dom), vec!["__azul-native-split-pane".to_string()]);
1662        assert_eq!(dom.root.get_tab_index(), Some(TabIndex::Auto));
1663        let children = dom.children.as_ref();
1664        assert_eq!(children.len(), 3);
1665        assert_eq!(
1666            dom_classes(&children[0]),
1667            vec!["__azul-native-split-pane-first".to_string()]
1668        );
1669        assert_eq!(
1670            dom_classes(&children[1]),
1671            vec!["__azul-native-split-pane-divider".to_string()]
1672        );
1673        assert_eq!(
1674            dom_classes(&children[2]),
1675            vec!["__azul-native-split-pane-second".to_string()]
1676        );
1677    }
1678
1679    #[test]
1680    fn dom_wraps_the_user_children_one_per_pane() {
1681        let dom = pane(div_with_class("alpha"), div_with_class("beta")).dom();
1682        let first = child(&dom, 0);
1683        let second = child(&dom, 2);
1684        assert_eq!(first.children.as_ref().len(), 1);
1685        assert_eq!(second.children.as_ref().len(), 1);
1686        assert_eq!(dom_classes(child(first, 0)), vec!["alpha".to_string()]);
1687        assert_eq!(dom_classes(child(second, 0)), vec!["beta".to_string()]);
1688        // The divider is a leaf: anything inside it would sit under the cursor
1689        // during a drag.
1690        assert!(child(&dom, 1).children.as_ref().is_empty());
1691    }
1692
1693    #[test]
1694    fn dom_pane_grows_are_complementary_for_every_ratio() {
1695        for r in IN_RANGE_RATIOS {
1696            let dom = plain(SplitDirection::Horizontal).with_ratio(r).dom();
1697            let a = inline_grow(child(&dom, 0)).expect("first pane grow");
1698            let b = inline_grow(child(&dom, 2)).expect("second pane grow");
1699            assert!((a - r).abs() < 2e-3, "ratio {r}: first pane got {a}");
1700            assert!((a + b - 1.0).abs() < 3e-3, "ratio {r}: {a} + {b} != 1");
1701        }
1702    }
1703
1704    #[test]
1705    fn dom_clamped_extremes_still_leave_both_panes_visible() {
1706        for r in [-100.0_f32, 0.0, 1.0, f32::INFINITY] {
1707            let dom = plain(SplitDirection::Horizontal).with_ratio(r).dom();
1708            let a = inline_grow(child(&dom, 0)).expect("first pane grow");
1709            let b = inline_grow(child(&dom, 2)).expect("second pane grow");
1710            assert!(a > 0.0 && b > 0.0, "ratio {r} collapsed a pane ({a}, {b})");
1711            assert!((a + b - 1.0).abs() < 3e-3, "ratio {r}: {a} + {b} != 1");
1712        }
1713    }
1714
1715    #[test]
1716    fn dom_with_a_nan_ratio_collapses_both_panes() {
1717        // Consequence of `set_ratio_nan_escapes_the_documented_clamp`: NaN
1718        // survives into the layout and both panes render at flex-grow: 0.
1719        let dom = plain(SplitDirection::Horizontal).with_ratio(f32::NAN).dom();
1720        assert_eq!(inline_grow(child(&dom, 0)), Some(0.0));
1721        assert_eq!(inline_grow(child(&dom, 2)), Some(0.0));
1722    }
1723
1724    #[test]
1725    fn dom_divider_matches_divider_style_for_the_direction() {
1726        for dir in BOTH_DIRECTIONS {
1727            let dom = plain(dir).dom();
1728            assert_eq!(
1729                inline_properties(child(&dom, 1)),
1730                properties(&divider_style(dir)),
1731                "{dir:?}"
1732            );
1733        }
1734    }
1735
1736    #[test]
1737    fn dom_container_keeps_the_configured_style() {
1738        for dir in BOTH_DIRECTIONS {
1739            let dom = plain(dir).dom();
1740            assert_eq!(
1741                inline_properties(&dom),
1742                properties(&container_style(dir)),
1743                "{dir:?}"
1744            );
1745        }
1746    }
1747
1748    #[test]
1749    fn dom_registers_every_pointer_event_on_the_container() {
1750        let dom = plain(SplitDirection::Horizontal).dom();
1751        let wired: Vec<(EventFilter, usize)> = dom
1752            .root
1753            .callbacks
1754            .as_ref()
1755            .iter()
1756            .map(|c| (c.event, c.callback.cb))
1757            .collect();
1758        let expected: Vec<(EventFilter, usize)> = vec![
1759            (
1760                EventFilter::Hover(HoverEventFilter::MouseDown),
1761                on_split_pointer_down as usize,
1762            ),
1763            (
1764                EventFilter::Hover(HoverEventFilter::MouseOver),
1765                on_split_pointer_move as usize,
1766            ),
1767            (
1768                EventFilter::Hover(HoverEventFilter::MouseUp),
1769                on_split_pointer_up as usize,
1770            ),
1771            (
1772                EventFilter::Hover(HoverEventFilter::MouseLeave),
1773                on_split_pointer_up as usize,
1774            ),
1775            (
1776                EventFilter::Hover(HoverEventFilter::TouchStart),
1777                on_split_pointer_down as usize,
1778            ),
1779            (
1780                EventFilter::Hover(HoverEventFilter::TouchMove),
1781                on_split_pointer_move as usize,
1782            ),
1783            (
1784                EventFilter::Hover(HoverEventFilter::TouchEnd),
1785                on_split_pointer_up as usize,
1786            ),
1787        ];
1788        assert_eq!(wired, expected);
1789        // The drag lives on the container, never on the divider or the panes -
1790        // otherwise the cursor would leave the callback node mid-drag.
1791        for i in 0..3 {
1792            assert!(
1793                child(&dom, i).root.callbacks.as_ref().is_empty(),
1794                "child {i} must not carry pointer callbacks"
1795            );
1796        }
1797    }
1798
1799    #[test]
1800    fn dom_shares_one_state_refany_across_all_callbacks() {
1801        let dom = plain(SplitDirection::Horizontal).dom();
1802        let mut first = dom.root.callbacks.as_ref()[0].refany.clone();
1803        let mut last = dom.root.callbacks.as_ref()[6].refany.clone();
1804        {
1805            let mut sp = first
1806                .downcast_mut::<SplitPaneStateWrapper>()
1807                .expect("state type");
1808            sp.is_dragging = true;
1809            sp.drag_start_px = 42.0;
1810        }
1811        let seen = wrapper(&mut last);
1812        assert!(
1813            seen.is_dragging && seen.drag_start_px == 42.0,
1814            "press/move/release must observe one shared drag state"
1815        );
1816    }
1817
1818    #[test]
1819    fn dom_carries_the_state_into_the_callback_payload() {
1820        let mut sp = plain(SplitDirection::Vertical).with_ratio(0.25);
1821        sp.set_on_resize(
1822            RefAny::new(ResizeLog::default()),
1823            record_resize as SplitPaneOnResizeCallbackType,
1824        );
1825        let (_sd, mut state) = laid_out(sp);
1826        let w = wrapper(&mut state);
1827        assert_eq!(w.inner.direction, SplitDirection::Vertical);
1828        assert_eq!(w.inner.ratio, 0.25);
1829        assert!(!w.is_dragging);
1830        assert!(w.on_resize.is_some());
1831    }
1832
1833    #[test]
1834    fn dom_of_deeply_nested_children_does_not_panic() {
1835        // A pathological child tree must survive the flatten that `dom()`
1836        // feeds into `StyledDom`.
1837        let mut deep = Dom::create_div();
1838        for _ in 0..200 {
1839            deep = Dom::create_div().with_children(vec![deep].into());
1840        }
1841        let (sd, _state) = laid_out(pane(deep, Dom::create_div()));
1842        assert!(sd.node_count() > 200);
1843    }
1844
1845    #[test]
1846    fn from_split_pane_for_dom_matches_dom() {
1847        let via_into: Dom = plain(SplitDirection::Vertical).with_ratio(0.3).into();
1848        let direct = plain(SplitDirection::Vertical).with_ratio(0.3).dom();
1849        assert_eq!(dom_classes(&via_into), dom_classes(&direct));
1850        assert_eq!(via_into.children.as_ref().len(), direct.children.as_ref().len());
1851        assert_eq!(inline_grow(child(&via_into, 0)), inline_grow(child(&direct, 0)));
1852    }
1853
1854    // ==================================================================
1855    // on_split_pointer_down  (other)
1856    // ==================================================================
1857
1858    #[test]
1859    fn pointer_down_without_a_cursor_is_a_no_op() {
1860        let (sd, state) = laid_out(plain(SplitDirection::Horizontal));
1861        let (update, changes) = drive(
1862            sd,
1863            &[(0, size(200.0, 100.0))],
1864            node(0),
1865            OptionLogicalPosition::None,
1866            |info| on_split_pointer_down(state.clone(), info),
1867        );
1868        assert_eq!(update, Update::DoNothing);
1869        assert!(changes.is_empty());
1870        let mut state = state;
1871        assert!(!wrapper(&mut state).is_dragging);
1872    }
1873
1874    #[test]
1875    fn pointer_down_without_a_laid_out_rect_is_a_no_op() {
1876        let (sd, state) = laid_out(plain(SplitDirection::Horizontal));
1877        let (update, changes) = drive(sd, &[], node(0), cursor(100.0, 50.0), |info| {
1878            on_split_pointer_down(state.clone(), info)
1879        });
1880        assert_eq!(update, Update::DoNothing);
1881        assert!(changes.is_empty());
1882        let mut state = state;
1883        assert!(!wrapper(&mut state).is_dragging);
1884    }
1885
1886    #[test]
1887    fn pointer_down_on_a_zero_sized_container_is_a_no_op() {
1888        let (sd, state) = laid_out(plain(SplitDirection::Horizontal));
1889        let (update, _) = drive(
1890            sd,
1891            &[(0, size(0.0, 0.0))],
1892            node(0),
1893            cursor(0.0, 0.0),
1894            |info| on_split_pointer_down(state.clone(), info),
1895        );
1896        assert_eq!(update, Update::DoNothing);
1897        let mut state = state;
1898        assert!(
1899            !wrapper(&mut state).is_dragging,
1900            "a 0-wide container has no divider to grab"
1901        );
1902    }
1903
1904    #[test]
1905    fn pointer_down_on_a_negative_sized_container_is_a_no_op() {
1906        let (sd, state) = laid_out(plain(SplitDirection::Horizontal));
1907        let (update, _) = drive(
1908            sd,
1909            &[(0, size(-200.0, -100.0))],
1910            node(0),
1911            cursor(-100.0, -50.0),
1912            |info| on_split_pointer_down(state.clone(), info),
1913        );
1914        assert_eq!(update, Update::DoNothing);
1915        let mut state = state;
1916        assert!(!wrapper(&mut state).is_dragging);
1917    }
1918
1919    #[test]
1920    fn pointer_down_records_the_anchor_when_it_lands_on_the_divider() {
1921        let (sd, state) = laid_out(plain(SplitDirection::Horizontal).with_ratio(0.25));
1922        // 200px wide, ratio 0.25 -> the grab zone is centred on x = 50.
1923        let (update, changes) = drive(
1924            sd,
1925            &[(0, size(200.0, 100.0))],
1926            node(0),
1927            cursor(52.0, 10.0),
1928            |info| on_split_pointer_down(state.clone(), info),
1929        );
1930        assert_eq!(update, Update::DoNothing, "the press itself never redraws");
1931        assert!(changes.is_empty(), "the press must not touch the DOM");
1932        let mut state = state;
1933        let w = wrapper(&mut state);
1934        assert!(w.is_dragging);
1935        assert_eq!(w.drag_start_px, 52.0);
1936        assert_eq!(w.ratio_at_drag_start, 0.25);
1937        assert_eq!(w.inner.ratio, 0.25, "the press must not move the divider");
1938    }
1939
1940    #[test]
1941    fn pointer_down_far_from_the_divider_leaves_the_press_to_the_pane() {
1942        let (sd, state) = laid_out(plain(SplitDirection::Horizontal));
1943        let (update, _) = drive(
1944            sd,
1945            &[(0, size(200.0, 100.0))],
1946            node(0),
1947            cursor(10.0, 50.0),
1948            |info| on_split_pointer_down(state.clone(), info),
1949        );
1950        assert_eq!(update, Update::DoNothing);
1951        let mut state = state;
1952        assert!(!wrapper(&mut state).is_dragging);
1953    }
1954
1955    #[test]
1956    fn pointer_down_grabs_exactly_within_the_threshold() {
1957        // 200px, ratio 0.5 -> divider centre at 100. GRAB_THRESHOLD is 9.0 and
1958        // the comparison is inclusive.
1959        for (x, expected) in [
1960            (100.0_f32, true),
1961            (109.0, true),
1962            (91.0, true),
1963            (109.5, false),
1964            (90.5, false),
1965            (0.0, false),
1966            (200.0, false),
1967        ] {
1968            let (sd, state) = laid_out(plain(SplitDirection::Horizontal));
1969            let (_, _) = drive(
1970                sd,
1971                &[(0, size(200.0, 100.0))],
1972                node(0),
1973                cursor(x, 50.0),
1974                |info| on_split_pointer_down(state.clone(), info),
1975            );
1976            let mut state = state;
1977            assert_eq!(
1978                wrapper(&mut state).is_dragging,
1979                expected,
1980                "press at x = {x} (|{x} - 100| vs {GRAB_THRESHOLD})"
1981            );
1982        }
1983    }
1984
1985    #[test]
1986    fn pointer_down_uses_the_axis_that_matches_the_direction() {
1987        // 200x100. Horizontal: centre x = 100. Vertical: centre y = 50.
1988        // The same cursor must grab in one orientation and miss in the other.
1989        for (dir, cur, expected) in [
1990            (SplitDirection::Horizontal, (100.0, 5.0), true),
1991            (SplitDirection::Vertical, (100.0, 5.0), false),
1992            (SplitDirection::Horizontal, (5.0, 50.0), false),
1993            (SplitDirection::Vertical, (5.0, 50.0), true),
1994        ] {
1995            let (sd, state) = laid_out(plain(dir));
1996            let (_, _) = drive(
1997                sd,
1998                &[(0, size(200.0, 100.0))],
1999                node(0),
2000                cursor(cur.0, cur.1),
2001                |info| on_split_pointer_down(state.clone(), info),
2002            );
2003            let mut state = state;
2004            assert_eq!(
2005                wrapper(&mut state).is_dragging,
2006                expected,
2007                "{dir:?} press at {cur:?}"
2008            );
2009        }
2010    }
2011
2012    #[test]
2013    fn pointer_down_with_a_nan_cursor_or_size_never_grabs() {
2014        for (s, cur) in [
2015            (size(200.0, 100.0), (f32::NAN, 50.0)),
2016            (size(f32::NAN, 100.0), (100.0, 50.0)),
2017            (size(f32::INFINITY, 100.0), (100.0, 50.0)),
2018            (size(200.0, 100.0), (f32::INFINITY, 50.0)),
2019        ] {
2020            let (sd, state) = laid_out(plain(SplitDirection::Horizontal));
2021            let (update, _) = drive(sd, &[(0, s)], node(0), cursor(cur.0, cur.1), |info| {
2022                on_split_pointer_down(state.clone(), info)
2023            });
2024            assert_eq!(update, Update::DoNothing);
2025            let mut state = state;
2026            assert!(
2027                !wrapper(&mut state).is_dragging,
2028                "a non-finite comparison must fail closed, not grab"
2029            );
2030        }
2031    }
2032
2033    #[test]
2034    fn pointer_down_with_a_wrong_typed_payload_is_a_no_op() {
2035        let (sd, _state) = laid_out(plain(SplitDirection::Horizontal));
2036        let stranger = RefAny::new(0u16);
2037        let (update, changes) = drive(
2038            sd,
2039            &[(0, size(200.0, 100.0))],
2040            node(0),
2041            cursor(100.0, 50.0),
2042            |info| on_split_pointer_down(stranger.clone(), info),
2043        );
2044        assert_eq!(update, Update::DoNothing);
2045        assert!(changes.is_empty());
2046    }
2047
2048    #[test]
2049    fn pointer_down_re_anchors_on_every_press() {
2050        let (sd, state) = laid_out(plain(SplitDirection::Horizontal));
2051        let boxes = [(0, size(200.0, 100.0))];
2052        for x in [95.0_f32, 104.0] {
2053            let sd_clone = sd.clone();
2054            let (_, _) = drive(sd_clone, &boxes, node(0), cursor(x, 50.0), |info| {
2055                on_split_pointer_down(state.clone(), info)
2056            });
2057        }
2058        let mut state = state;
2059        let w = wrapper(&mut state);
2060        assert!(w.is_dragging);
2061        assert_eq!(w.drag_start_px, 104.0, "the newest press wins");
2062    }
2063
2064    // ==================================================================
2065    // on_split_pointer_move  (other)
2066    // ==================================================================
2067
2068    /// Presses at `press` (which must land on the divider), then moves to
2069    /// `to`. Returns the move's `Update` plus the CSS writes it made.
2070    fn press_then_move(
2071        sp: SplitPane,
2072        boxes: &[(usize, LogicalSize)],
2073        press: (f32, f32),
2074        to: (f32, f32),
2075    ) -> (Update, Vec<CallbackChange>, RefAny) {
2076        let (sd, state) = laid_out(sp);
2077        let down_sd = sd.clone();
2078        let (_, _) = drive(
2079            down_sd,
2080            boxes,
2081            node(0),
2082            cursor(press.0, press.1),
2083            |info| on_split_pointer_down(state.clone(), info),
2084        );
2085        let (update, changes) = drive(sd, boxes, node(0), cursor(to.0, to.1), |info| {
2086            on_split_pointer_move(state.clone(), info)
2087        });
2088        (update, changes, state)
2089    }
2090
2091    #[test]
2092    fn pointer_move_without_a_drag_changes_nothing() {
2093        let (sd, state) = laid_out(plain(SplitDirection::Horizontal));
2094        let (update, changes) = drive(
2095            sd,
2096            &[(0, size(200.0, 100.0))],
2097            node(0),
2098            cursor(180.0, 50.0),
2099            |info| on_split_pointer_move(state.clone(), info),
2100        );
2101        assert_eq!(update, Update::DoNothing);
2102        assert!(changes.is_empty(), "a hover must not resize the panes");
2103        let mut state = state;
2104        assert_eq!(wrapper(&mut state).inner.ratio, 0.5);
2105    }
2106
2107    #[test]
2108    fn pointer_move_applies_the_cursor_delta_and_resizes_both_panes() {
2109        let boxes = [(0, size(200.0, 100.0))];
2110        let (update, changes, mut state) = press_then_move(
2111            plain(SplitDirection::Horizontal),
2112            &boxes,
2113            (100.0, 50.0),
2114            (150.0, 50.0),
2115        );
2116        // +50px over a 200px container = +0.25 on the anchor ratio of 0.5.
2117        assert_eq!(wrapper(&mut state).inner.ratio, 0.75);
2118        assert_eq!(update, Update::DoNothing, "no hook installed");
2119
2120        let writes = css_changes(&changes);
2121        assert_eq!(writes.len(), 2, "exactly one flex-grow per pane");
2122        assert_eq!(writes[0].1, 0.75);
2123        assert_eq!(writes[1].1, 0.25);
2124        assert_ne!(writes[0].0, writes[1].0, "the two panes must be distinct nodes");
2125    }
2126
2127    #[test]
2128    fn pointer_move_writes_to_the_first_and_third_children() {
2129        // The handler walks first_child -> next_sibling -> next_sibling, so
2130        // the pane/divider/pane order in `dom()` is load-bearing.
2131        let boxes = [(0, size(200.0, 100.0))];
2132        let (sd, state) = laid_out(plain(SplitDirection::Horizontal));
2133        let classes: Vec<Vec<String>> = sd
2134            .node_data
2135            .as_ref()
2136            .iter()
2137            .map(|n| {
2138                n.get_ids_and_classes()
2139                    .as_ref()
2140                    .iter()
2141                    .filter_map(|c| match c {
2142                        IdOrClass::Class(s) => Some(s.as_str().to_string()),
2143                        IdOrClass::Id(_) => None,
2144                    })
2145                    .collect()
2146            })
2147            .collect();
2148        let down_sd = sd.clone();
2149        let (_, _) = drive(down_sd, &boxes, node(0), cursor(100.0, 50.0), |info| {
2150            on_split_pointer_down(state.clone(), info)
2151        });
2152        let (_, changes) = drive(sd, &boxes, node(0), cursor(150.0, 50.0), |info| {
2153            on_split_pointer_move(state.clone(), info)
2154        });
2155        let writes = css_changes(&changes);
2156        assert_eq!(writes.len(), 2);
2157        assert_eq!(
2158            classes[writes[0].0.index()],
2159            vec!["__azul-native-split-pane-first".to_string()],
2160            "the larger grow must land on the first pane"
2161        );
2162        assert_eq!(
2163            classes[writes[1].0.index()],
2164            vec!["__azul-native-split-pane-second".to_string()]
2165        );
2166    }
2167
2168    #[test]
2169    fn pointer_move_clamps_at_both_ends_and_never_collapses_a_pane() {
2170        let boxes = [(0, size(200.0, 100.0))];
2171        for (to_x, expected) in [(-10_000.0_f32, MIN_RATIO), (10_000.0, MAX_RATIO)] {
2172            let (_, changes, mut state) = press_then_move(
2173                plain(SplitDirection::Horizontal),
2174                &boxes,
2175                (100.0, 50.0),
2176                (to_x, 50.0),
2177            );
2178            assert_eq!(wrapper(&mut state).inner.ratio, expected, "moved to {to_x}");
2179            let writes = css_changes(&changes);
2180            assert_eq!(writes.len(), 2);
2181            assert!(writes[0].1 > 0.0 && writes[1].1 > 0.0, "moved to {to_x}");
2182            assert!((writes[0].1 + writes[1].1 - 1.0).abs() < 3e-3, "moved to {to_x}");
2183        }
2184    }
2185
2186    #[test]
2187    fn pointer_move_grows_always_sum_to_one() {
2188        let boxes = [(0, size(400.0, 100.0))];
2189        for to_x in [0.0_f32, 40.0, 133.0, 200.0, 267.0, 360.0, 400.0] {
2190            let (_, changes, _) = press_then_move(
2191                plain(SplitDirection::Horizontal),
2192                &boxes,
2193                (200.0, 50.0),
2194                (to_x, 50.0),
2195            );
2196            let writes = css_changes(&changes);
2197            assert_eq!(writes.len(), 2, "moved to {to_x}");
2198            // Budget: the ×1000 truncation can shave up to 0.001 off each pane.
2199            assert!(
2200                (writes[0].1 + writes[1].1 - 1.0).abs() < 3e-3,
2201                "moved to {to_x}: {} + {} != 1",
2202                writes[0].1,
2203                writes[1].1
2204            );
2205        }
2206    }
2207
2208    #[test]
2209    fn pointer_move_is_anchor_relative_not_cursor_absolute() {
2210        // Grabbing the divider off-centre must not teleport it under the
2211        // cursor: the ratio moves by the *delta*, from the ratio at press.
2212        let boxes = [(0, size(200.0, 100.0))];
2213        let (_, _, mut state) = press_then_move(
2214            plain(SplitDirection::Horizontal),
2215            &boxes,
2216            (108.0, 50.0),
2217            (128.0, 50.0),
2218        );
2219        // press at 108 (within 9 of the 100 centre), moved +20 over 200px.
2220        let r = wrapper(&mut state).inner.ratio;
2221        assert!((r - 0.6).abs() < 1e-6, "expected 0.5 + 20/200, got {r}");
2222    }
2223
2224    #[test]
2225    fn pointer_move_back_to_the_press_point_restores_the_ratio() {
2226        let boxes = [(0, size(200.0, 100.0))];
2227        let (sd, state) = laid_out(plain(SplitDirection::Horizontal).with_ratio(0.4));
2228        // ratio 0.4 over 200px -> the grab zone is centred on x = 80.
2229        let a = sd.clone();
2230        let (_, _) = drive(a, &boxes, node(0), cursor(80.0, 50.0), |info| {
2231            on_split_pointer_down(state.clone(), info)
2232        });
2233        let b = sd.clone();
2234        let (_, _) = drive(b, &boxes, node(0), cursor(160.0, 50.0), |info| {
2235            on_split_pointer_move(state.clone(), info)
2236        });
2237        let (_, changes) = drive(sd, &boxes, node(0), cursor(80.0, 50.0), |info| {
2238            on_split_pointer_move(state.clone(), info)
2239        });
2240        let mut state = state;
2241        assert_eq!(wrapper(&mut state).inner.ratio, 0.4, "the drag is not cumulative");
2242        assert_eq!(css_changes(&changes)[0].1, 0.4);
2243    }
2244
2245    #[test]
2246    fn pointer_move_uses_the_axis_that_matches_the_direction() {
2247        let boxes = [(0, size(200.0, 100.0))];
2248        // Vertical: centre y = 50, main size = 100. +25px = +0.25.
2249        let (_, _, mut state) = press_then_move(
2250            plain(SplitDirection::Vertical),
2251            &boxes,
2252            (10.0, 50.0),
2253            (999.0, 75.0),
2254        );
2255        assert_eq!(
2256            wrapper(&mut state).inner.ratio,
2257            0.75,
2258            "a vertical split must ignore horizontal cursor motion"
2259        );
2260    }
2261
2262    #[test]
2263    fn pointer_move_fires_the_hook_with_the_new_state_and_returns_its_update() {
2264        let boxes = [(0, size(200.0, 100.0))];
2265        let mut log = RefAny::new(ResizeLog::default());
2266        let sp = plain(SplitDirection::Horizontal)
2267            .with_on_resize(log.clone(), record_resize as SplitPaneOnResizeCallbackType);
2268        let (update, _, _) = press_then_move(sp, &boxes, (100.0, 50.0), (150.0, 50.0));
2269        assert_eq!(update, Update::RefreshDom, "the hook's Update is returned verbatim");
2270        let seen = logged(&mut log);
2271        assert_eq!(seen.len(), 1);
2272        assert_eq!(seen[0].ratio, 0.75);
2273        assert_eq!(seen[0].direction, SplitDirection::Horizontal);
2274    }
2275
2276    #[test]
2277    fn pointer_move_returns_the_hooks_do_nothing_too() {
2278        let boxes = [(0, size(200.0, 100.0))];
2279        let sp = plain(SplitDirection::Horizontal).with_on_resize(
2280            RefAny::new(ResizeLog::default()),
2281            resize_do_nothing as SplitPaneOnResizeCallbackType,
2282        );
2283        let (update, changes, _) = press_then_move(sp, &boxes, (100.0, 50.0), (150.0, 50.0));
2284        assert_eq!(update, Update::DoNothing);
2285        assert_eq!(css_changes(&changes).len(), 2, "the panes still resize");
2286    }
2287
2288    #[test]
2289    fn pointer_move_fires_the_hook_even_when_the_ratio_did_not_change() {
2290        let boxes = [(0, size(200.0, 100.0))];
2291        let mut log = RefAny::new(ResizeLog::default());
2292        let sp = plain(SplitDirection::Horizontal)
2293            .with_on_resize(log.clone(), record_resize as SplitPaneOnResizeCallbackType);
2294        let (_, changes, mut state) = press_then_move(sp, &boxes, (100.0, 50.0), (100.0, 50.0));
2295        assert_eq!(wrapper(&mut state).inner.ratio, 0.5);
2296        assert_eq!(logged(&mut log).len(), 1, "a zero-delta move still notifies");
2297        assert_eq!(css_changes(&changes).len(), 2);
2298    }
2299
2300    #[test]
2301    fn pointer_move_without_a_cursor_or_rect_keeps_the_drag_alive() {
2302        let boxes = [(0, size(200.0, 100.0))];
2303        let (sd, state) = laid_out(plain(SplitDirection::Horizontal));
2304        let a = sd.clone();
2305        let (_, _) = drive(a, &boxes, node(0), cursor(100.0, 50.0), |info| {
2306            on_split_pointer_down(state.clone(), info)
2307        });
2308        // No cursor.
2309        let b = sd.clone();
2310        let (u1, c1) = drive(b, &boxes, node(0), OptionLogicalPosition::None, |info| {
2311            on_split_pointer_move(state.clone(), info)
2312        });
2313        // No laid-out rect.
2314        let (u2, c2) = drive(sd, &[], node(0), cursor(150.0, 50.0), |info| {
2315            on_split_pointer_move(state.clone(), info)
2316        });
2317        assert_eq!((u1, u2), (Update::DoNothing, Update::DoNothing));
2318        assert!(c1.is_empty() && c2.is_empty());
2319        let mut state = state;
2320        let w = wrapper(&mut state);
2321        assert!(w.is_dragging, "a dropped move event must not cancel the drag");
2322        assert_eq!(w.inner.ratio, 0.5);
2323    }
2324
2325    #[test]
2326    fn pointer_move_on_a_zero_sized_container_is_a_no_op() {
2327        let boxes = [(0, size(200.0, 100.0))];
2328        let (sd, state) = laid_out(plain(SplitDirection::Horizontal));
2329        let a = sd.clone();
2330        let (_, _) = drive(a, &boxes, node(0), cursor(100.0, 50.0), |info| {
2331            on_split_pointer_down(state.clone(), info)
2332        });
2333        let (update, changes) = drive(
2334            sd,
2335            &[(0, size(0.0, 0.0))],
2336            node(0),
2337            cursor(0.0, 0.0),
2338            |info| on_split_pointer_move(state.clone(), info),
2339        );
2340        assert_eq!(update, Update::DoNothing);
2341        assert!(changes.is_empty(), "no division by a zero main size");
2342        let mut state = state;
2343        assert_eq!(wrapper(&mut state).inner.ratio, 0.5);
2344    }
2345
2346    #[test]
2347    fn pointer_move_with_a_wrong_typed_payload_is_a_no_op() {
2348        let (sd, _state) = laid_out(plain(SplitDirection::Horizontal));
2349        let stranger = RefAny::new([0u8; 3]);
2350        let (update, changes) = drive(
2351            sd,
2352            &[(0, size(200.0, 100.0))],
2353            node(0),
2354            cursor(150.0, 50.0),
2355            |info| on_split_pointer_move(stranger.clone(), info),
2356        );
2357        assert_eq!(update, Update::DoNothing);
2358        assert!(changes.is_empty());
2359    }
2360
2361    #[test]
2362    fn pointer_move_on_a_childless_hit_node_still_tracks_the_ratio() {
2363        // The handler assumes the hit node is the container. Aim it at a leaf
2364        // (the divider's DOM node) instead: the CSS writes are skipped, but
2365        // nothing panics and the ratio bookkeeping still runs.
2366        let boxes = [(0, size(200.0, 100.0)), (3, size(6.0, 100.0))];
2367        let (sd, state) = laid_out(plain(SplitDirection::Horizontal));
2368        let a = sd.clone();
2369        let (_, _) = drive(a, &boxes, node(0), cursor(100.0, 50.0), |info| {
2370            on_split_pointer_down(state.clone(), info)
2371        });
2372        let (update, changes) = drive(sd, &boxes, node(3), cursor(3.0, 50.0), |info| {
2373            on_split_pointer_move(state.clone(), info)
2374        });
2375        assert_eq!(update, Update::DoNothing);
2376        assert!(
2377            css_changes(&changes).len() <= 2,
2378            "at most the two panes may be written"
2379        );
2380        let mut state = state;
2381        assert!(wrapper(&mut state).inner.ratio.is_finite());
2382    }
2383
2384    #[test]
2385    fn pointer_move_with_a_nan_cursor_poisons_the_ratio() {
2386        // PIN + KNOWN DEFECT: `clamp` passes NaN through, so a NaN cursor
2387        // leaves the widget with a NaN ratio, which then encodes as
2388        // flex-grow: 0 on BOTH panes (an invisible split). No panic, but the
2389        // documented `[MIN_RATIO, MAX_RATIO]` invariant is broken.
2390        let boxes = [(0, size(200.0, 100.0))];
2391        let (_, changes, mut state) = press_then_move(
2392            plain(SplitDirection::Horizontal),
2393            &boxes,
2394            (100.0, 50.0),
2395            (f32::NAN, 50.0),
2396        );
2397        assert!(wrapper(&mut state).inner.ratio.is_nan());
2398        let writes = css_changes(&changes);
2399        assert_eq!(writes.len(), 2);
2400        assert_eq!(writes[0].1, 0.0);
2401        assert_eq!(writes[1].1, 0.0);
2402    }
2403
2404    #[test]
2405    fn pointer_move_with_a_nan_container_size_poisons_the_ratio() {
2406        // Same defect from the other side: `msize <= 0.0` does not reject NaN,
2407        // so `delta / NaN` reaches the clamp. Pinned, not endorsed.
2408        let (sd, state) = laid_out(plain(SplitDirection::Horizontal));
2409        let a = sd.clone();
2410        let (_, _) = drive(
2411            a,
2412            &[(0, size(200.0, 100.0))],
2413            node(0),
2414            cursor(100.0, 50.0),
2415            |info| on_split_pointer_down(state.clone(), info),
2416        );
2417        let (update, _) = drive(
2418            sd,
2419            &[(0, size(f32::NAN, 100.0))],
2420            node(0),
2421            cursor(150.0, 50.0),
2422            |info| on_split_pointer_move(state.clone(), info),
2423        );
2424        assert_eq!(update, Update::DoNothing);
2425        let mut state = state;
2426        assert!(wrapper(&mut state).inner.ratio.is_nan());
2427    }
2428
2429    #[test]
2430    fn pointer_move_with_extreme_cursors_stays_inside_the_clamp() {
2431        let boxes = [(0, size(200.0, 100.0))];
2432        for x in [f32::MAX, f32::MIN, 1.0e30, -1.0e30, f32::INFINITY, f32::NEG_INFINITY] {
2433            let (_, _, mut state) = press_then_move(
2434                plain(SplitDirection::Horizontal),
2435                &boxes,
2436                (100.0, 50.0),
2437                (x, 50.0),
2438            );
2439            let r = wrapper(&mut state).inner.ratio;
2440            assert!(
2441                (MIN_RATIO..=MAX_RATIO).contains(&r),
2442                "cursor {x} produced ratio {r}"
2443            );
2444        }
2445    }
2446
2447    #[test]
2448    fn pointer_move_on_a_huge_container_is_still_a_proportion() {
2449        let boxes = [(0, size(1.0e9, 100.0))];
2450        let (_, _, mut state) = press_then_move(
2451            plain(SplitDirection::Horizontal),
2452            &boxes,
2453            (5.0e8, 50.0),
2454            (7.5e8, 50.0),
2455        );
2456        let r = wrapper(&mut state).inner.ratio;
2457        assert!((r - 0.75).abs() < 1e-4, "expected ~0.75, got {r}");
2458    }
2459
2460    #[test]
2461    fn pointer_move_on_a_sub_pixel_container_does_not_explode() {
2462        let boxes = [(0, size(f32::MIN_POSITIVE, 100.0))];
2463        let (_, _, mut state) = press_then_move(
2464            plain(SplitDirection::Horizontal),
2465            &boxes,
2466            (0.0, 50.0),
2467            (1.0, 50.0),
2468        );
2469        // delta / MIN_POSITIVE is astronomically large; the clamp catches it
2470        // instead of letting a garbage ratio through.
2471        assert_eq!(wrapper(&mut state).inner.ratio, MAX_RATIO);
2472    }
2473
2474    // ==================================================================
2475    // on_split_pointer_up  (other)
2476    // ==================================================================
2477
2478    #[test]
2479    fn pointer_up_ends_the_drag_and_keeps_the_ratio() {
2480        let boxes = [(0, size(200.0, 100.0))];
2481        let (sd, state) = laid_out(plain(SplitDirection::Horizontal));
2482        let a = sd.clone();
2483        let (_, _) = drive(a, &boxes, node(0), cursor(100.0, 50.0), |info| {
2484            on_split_pointer_down(state.clone(), info)
2485        });
2486        let b = sd.clone();
2487        let (_, _) = drive(b, &boxes, node(0), cursor(150.0, 50.0), |info| {
2488            on_split_pointer_move(state.clone(), info)
2489        });
2490        let (update, changes) = drive(sd, &boxes, node(0), cursor(150.0, 50.0), |info| {
2491            on_split_pointer_up(state.clone(), info)
2492        });
2493        assert_eq!(update, Update::DoNothing);
2494        assert!(changes.is_empty(), "release must not rewrite the panes");
2495        let mut state = state;
2496        let w = wrapper(&mut state);
2497        assert!(!w.is_dragging);
2498        assert_eq!(w.inner.ratio, 0.75, "the drag result survives the release");
2499    }
2500
2501    #[test]
2502    fn pointer_up_is_idempotent_and_safe_without_a_drag() {
2503        let (sd, state) = laid_out(plain(SplitDirection::Horizontal));
2504        let a = sd.clone();
2505        let (u1, _) = drive(a, &[], node_none(), OptionLogicalPosition::None, |info| {
2506            on_split_pointer_up(state.clone(), info)
2507        });
2508        let (u2, _) = drive(sd, &[], node_none(), OptionLogicalPosition::None, |info| {
2509            on_split_pointer_up(state.clone(), info)
2510        });
2511        assert_eq!((u1, u2), (Update::DoNothing, Update::DoNothing));
2512        let mut state = state;
2513        let w = wrapper(&mut state);
2514        assert!(!w.is_dragging);
2515        assert_eq!(w.inner.ratio, 0.5);
2516    }
2517
2518    #[test]
2519    fn pointer_up_with_a_wrong_typed_payload_is_a_no_op() {
2520        let (sd, _state) = laid_out(plain(SplitDirection::Horizontal));
2521        let stranger = RefAny::new("not a split pane".to_string());
2522        let (update, changes) = drive(sd, &[], node_none(), OptionLogicalPosition::None, |info| {
2523            on_split_pointer_up(stranger.clone(), info)
2524        });
2525        assert_eq!(update, Update::DoNothing);
2526        assert!(changes.is_empty());
2527    }
2528
2529    #[test]
2530    fn a_released_drag_ignores_further_motion() {
2531        let boxes = [(0, size(200.0, 100.0))];
2532        let (sd, state) = laid_out(plain(SplitDirection::Horizontal));
2533        let a = sd.clone();
2534        let (_, _) = drive(a, &boxes, node(0), cursor(100.0, 50.0), |info| {
2535            on_split_pointer_down(state.clone(), info)
2536        });
2537        let b = sd.clone();
2538        let (_, _) = drive(b, &boxes, node(0), cursor(150.0, 50.0), |info| {
2539            on_split_pointer_move(state.clone(), info)
2540        });
2541        let c = sd.clone();
2542        let (_, _) = drive(c, &boxes, node(0), cursor(150.0, 50.0), |info| {
2543            on_split_pointer_up(state.clone(), info)
2544        });
2545        let (update, changes) = drive(sd, &boxes, node(0), cursor(20.0, 50.0), |info| {
2546            on_split_pointer_move(state.clone(), info)
2547        });
2548        assert_eq!(update, Update::DoNothing);
2549        assert!(changes.is_empty(), "post-release motion must not resize");
2550        let mut state = state;
2551        assert_eq!(wrapper(&mut state).inner.ratio, 0.75);
2552    }
2553}