1
//! Pagination widget — a page-number navigator: `Prev`, a joined row of
2
//! page-number buttons, then `Next`. A near-clone of
3
//! [`crate::widgets::segmented::Segmented`] (a joined button bar whose clicked
4
//! item is derived from sibling position and whose active item is live-restyled
5
//! via `set_css_property`), specialised to page navigation.
6
//!
7
//! State is `{ current_page, total_pages }` (`current_page` is 1-based). Clicking
8
//! a page button selects it; clicking `Prev`/`Next` steps one page within
9
//! `[1, total_pages]`. Any change updates `current_page`, invokes the optional
10
//! `on_change(state)`, and live-restyles every button (the active page gets the
11
//! accent fill + white text; the others the neutral fill). `Prev`/`Next` show a
12
//! muted "disabled" text colour (style only) when `current_page` is at the
13
//! respective end; clicking a disabled end (or the already-current page) is a
14
//! no-op (returns `Update::DoNothing`, fires no callback).
15
//!
16
//! Index derivation: the children are `[Prev, page1 … pageN, Next]`, so page `p`
17
//! sits at sibling position `p`, `Prev` at position `0` and `Next` at the last
18
//! position. The handler reads the clicked node's position and the live child
19
//! count, so it stays correct regardless of `total_pages` drift.
20
//!
21
//! Key types: [`Pagination`], [`PaginationState`], [`PaginationOnChange`].
22

            
23
use std::vec::Vec;
24

            
25
use azul_core::{
26
    callbacks::{CoreCallbackData, Update},
27
    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
28
    refany::RefAny,
29
};
30
use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
31
use azul_css::{
32
    props::{
33
        basic::{color::ColorU, StyleFontSize},
34
        layout::{LayoutDisplay, LayoutFlexDirection, LayoutAlignItems, LayoutAlignSelf, LayoutFlexGrow, LayoutJustifyContent, LayoutMinWidth, LayoutPaddingTop, LayoutPaddingBottom, LayoutPaddingLeft, LayoutPaddingRight},
35
        property::{CssProperty, *},
36
        style::{StyleBackgroundContent, StyleBackgroundContentVec, LayoutBorderTopWidth, LayoutBorderBottomWidth, LayoutBorderRightWidth, StyleBorderTopStyle, BorderStyle, StyleBorderBottomStyle, StyleBorderRightStyle, StyleBorderTopColor, StyleBorderBottomColor, StyleBorderRightColor, StyleCursor, StyleTextAlign, StyleUserSelect, StyleTextColor, LayoutBorderLeftWidth, StyleBorderLeftStyle, StyleBorderLeftColor, StyleBorderTopLeftRadius, StyleBorderBottomLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomRightRadius},
37
    },
38
    impl_option_inner, AzString,
39
};
40

            
41
use crate::callbacks::{Callback, CallbackInfo};
42

            
43
static PAGINATION_CLASS: &[IdOrClass] =
44
    &[Class(AzString::from_const_str("__azul-native-pagination"))];
45
static PAGINATION_PAGE_CLASS: &[IdOrClass] =
46
    &[Class(AzString::from_const_str("__azul-native-pagination-page"))];
47
static PAGINATION_NAV_CLASS: &[IdOrClass] =
48
    &[Class(AzString::from_const_str("__azul-native-pagination-nav"))];
49

            
50
const PREV_LABEL: AzString = AzString::from_const_str("Prev");
51
const NEXT_LABEL: AzString = AzString::from_const_str("Next");
52

            
53
/// Callback function type invoked when the current page changes.
54
pub type PaginationOnChangeCallbackType =
55
    extern "C" fn(RefAny, CallbackInfo, PaginationState) -> Update;
56
impl_widget_callback!(
57
    PaginationOnChange,
58
    OptionPaginationOnChange,
59
    PaginationOnChangeCallback,
60
    PaginationOnChangeCallbackType
61
);
62

            
63
azul_core::impl_managed_callback! {
64
    wrapper:        PaginationOnChangeCallback,
65
    info_ty:        CallbackInfo,
66
    return_ty:      Update,
67
    default_ret:    Update::DoNothing,
68
    invoker_static: PAGINATION_ON_CHANGE_INVOKER,
69
    invoker_ty:     AzPaginationOnChangeCallbackInvoker,
70
    thunk_fn:       az_pagination_on_change_callback_thunk,
71
    setter_fn:      AzApp_setPaginationOnChangeCallbackInvoker,
72
    from_handle_fn: AzPaginationOnChangeCallback_createFromHostHandle,
73
    extra_args:     [ state: PaginationState ],
74
}
75

            
76
/// A `Prev` / page-numbers / `Next` page navigator with a change callback.
77
#[derive(Debug, Clone, PartialEq, Eq)]
78
#[repr(C)]
79
pub struct Pagination {
80
    pub pagination_state: PaginationStateWrapper,
81
    /// Style for the row container.
82
    pub container_style: CssPropertyWithConditionsVec,
83
}
84

            
85
#[derive(Debug, Default, Clone, PartialEq, Eq)]
86
#[repr(C)]
87
pub struct PaginationStateWrapper {
88
    /// The current page + total page count.
89
    pub inner: PaginationState,
90
    /// Optional: function to call when the current page changes.
91
    pub on_change: OptionPaginationOnChange,
92
}
93

            
94
/// State of a [`Pagination`]: the current (1-based) page and the total page count.
95
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
96
#[repr(C)]
97
pub struct PaginationState {
98
    /// The 1-based index of the current page.
99
    pub current_page: usize,
100
    /// The total number of pages.
101
    pub total_pages: usize,
102
}
103

            
104
// ---- colours (mirroring segmented's palette) ----
105
/// Page border colour (#ced4da).
106
const PAGE_BORDER_COLOR: ColorU = ColorU { r: 206, g: 212, b: 218, a: 255 };
107
/// Active-page background (#0d6efd, accent blue).
108
const ACCENT_BG_COLOR: ColorU = ColorU { r: 13, g: 110, b: 253, a: 255 };
109
/// Neutral (inactive) background (white).
110
const NEUTRAL_BG_COLOR: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
111
/// Active-page text colour (white).
112
const ACTIVE_TEXT: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
113
/// Neutral text colour (#212529, dark).
114
const NEUTRAL_TEXT: ColorU = ColorU { r: 33, g: 37, b: 41, a: 255 };
115
/// Disabled (Prev/Next at a bound) text colour (#adb5bd, muted grey).
116
const DISABLED_TEXT: ColorU = ColorU { r: 173, g: 181, b: 189, a: 255 };
117

            
118
const ACCENT_BG_ITEMS: &[StyleBackgroundContent] =
119
    &[StyleBackgroundContent::Color(ACCENT_BG_COLOR)];
120
const ACCENT_BG: StyleBackgroundContentVec =
121
    StyleBackgroundContentVec::from_const_slice(ACCENT_BG_ITEMS);
122
const NEUTRAL_BG_ITEMS: &[StyleBackgroundContent] =
123
    &[StyleBackgroundContent::Color(NEUTRAL_BG_COLOR)];
124
const NEUTRAL_BG: StyleBackgroundContentVec =
125
    StyleBackgroundContentVec::from_const_slice(NEUTRAL_BG_ITEMS);
126

            
127
const PAGE_RADIUS: isize = 6;
128

            
129
/// Row container: a horizontal flex row that hugs its content.
130
static PAGINATION_CONTAINER_STYLE: &[CssPropertyWithConditions] = &[
131
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
132
    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)),
133
    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
134
    CssPropertyWithConditions::simple(CssProperty::align_self(LayoutAlignSelf::Start)),
135
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
136
];
137

            
138
/// Builds the style for one button. The active/disabled colours and the rounding
139
/// of the outer corners (only the first button — `Prev` — is rounded on the left,
140
/// only the last — `Next` — on the right) are position-dependent, so the style is
141
/// built at runtime (mirroring `segmented::build_segment_style`).
142
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
143
#[allow(clippy::fn_params_excessive_bools)] // independent boolean render flags, not a state enum
144
2607
fn build_button_style(
145
2607
    active: bool,
146
2607
    disabled: bool,
147
2607
    is_first: bool,
148
2607
    is_last: bool,
149
2607
) -> CssPropertyWithConditionsVec {
150
2607
    let bg = if active { ACCENT_BG } else { NEUTRAL_BG };
151
2607
    let text = if active {
152
145
        ACTIVE_TEXT
153
2462
    } else if disabled {
154
83
        DISABLED_TEXT
155
    } else {
156
2379
        NEUTRAL_TEXT
157
    };
158

            
159
2607
    let mut v: Vec<CssPropertyWithConditions> = vec![
160
2607
        CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
161
2607
        CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
162
2607
            LayoutFlexDirection::Row,
163
        )),
164
2607
        CssPropertyWithConditions::simple(CssProperty::const_justify_content(
165
2607
            LayoutJustifyContent::Center,
166
        )),
167
2607
        CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
168
2607
        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
169
            0,
170
        ))),
171
        // Keep single-digit page buttons from collapsing too narrow.
172
2607
        CssPropertyWithConditions::simple(CssProperty::const_min_width(LayoutMinWidth::const_px(
173
            36,
174
        ))),
175
        // padding: 6px 12px
176
2607
        CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
177
            6,
178
        ))),
179
2607
        CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
180
2607
            LayoutPaddingBottom::const_px(6),
181
        )),
182
2607
        CssPropertyWithConditions::simple(CssProperty::const_padding_left(
183
2607
            LayoutPaddingLeft::const_px(12),
184
        )),
185
2607
        CssPropertyWithConditions::simple(CssProperty::const_padding_right(
186
2607
            LayoutPaddingRight::const_px(12),
187
        )),
188
        // top/bottom/right borders (the left border is added only for the first
189
        // button, so adjacent buttons share a single 1px separator)
190
2607
        CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
191
2607
            LayoutBorderTopWidth::const_px(1),
192
        )),
193
2607
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
194
2607
            LayoutBorderBottomWidth::const_px(1),
195
        )),
196
2607
        CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
197
2607
            LayoutBorderRightWidth::const_px(1),
198
        )),
199
2607
        CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
200
2607
            inner: BorderStyle::Solid,
201
2607
        })),
202
2607
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
203
2607
            StyleBorderBottomStyle {
204
2607
                inner: BorderStyle::Solid,
205
2607
            },
206
        )),
207
2607
        CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
208
2607
            StyleBorderRightStyle {
209
2607
                inner: BorderStyle::Solid,
210
2607
            },
211
        )),
212
2607
        CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
213
2607
            inner: PAGE_BORDER_COLOR,
214
2607
        })),
215
2607
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
216
2607
            StyleBorderBottomColor {
217
2607
                inner: PAGE_BORDER_COLOR,
218
2607
            },
219
        )),
220
2607
        CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
221
2607
            StyleBorderRightColor {
222
2607
                inner: PAGE_BORDER_COLOR,
223
2607
            },
224
        )),
225
2607
        CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
226
2607
        CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
227
2607
        CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
228
2607
        CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
229
2607
        CssPropertyWithConditions::simple(CssProperty::const_background_content(bg)),
230
2607
        CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
231
2607
            inner: text,
232
2607
        })),
233
    ];
234

            
235
2607
    if is_first {
236
148
        v.push(CssPropertyWithConditions::simple(
237
148
            CssProperty::const_border_left_width(LayoutBorderLeftWidth::const_px(1)),
238
148
        ));
239
148
        v.push(CssPropertyWithConditions::simple(
240
148
            CssProperty::const_border_left_style(StyleBorderLeftStyle {
241
148
                inner: BorderStyle::Solid,
242
148
            }),
243
148
        ));
244
148
        v.push(CssPropertyWithConditions::simple(
245
148
            CssProperty::const_border_left_color(StyleBorderLeftColor {
246
148
                inner: PAGE_BORDER_COLOR,
247
148
            }),
248
148
        ));
249
148
        v.push(CssPropertyWithConditions::simple(
250
148
            CssProperty::const_border_top_left_radius(StyleBorderTopLeftRadius::const_px(
251
148
                PAGE_RADIUS,
252
148
            )),
253
148
        ));
254
148
        v.push(CssPropertyWithConditions::simple(
255
148
            CssProperty::const_border_bottom_left_radius(StyleBorderBottomLeftRadius::const_px(
256
148
                PAGE_RADIUS,
257
148
            )),
258
148
        ));
259
2459
    }
260
2607
    if is_last {
261
148
        v.push(CssPropertyWithConditions::simple(
262
148
            CssProperty::const_border_top_right_radius(StyleBorderTopRightRadius::const_px(
263
148
                PAGE_RADIUS,
264
148
            )),
265
148
        ));
266
148
        v.push(CssPropertyWithConditions::simple(
267
148
            CssProperty::const_border_bottom_right_radius(StyleBorderBottomRightRadius::const_px(
268
148
                PAGE_RADIUS,
269
148
            )),
270
148
        ));
271
2459
    }
272

            
273
2607
    CssPropertyWithConditionsVec::from_vec(v)
274
2607
}
275

            
276
impl Pagination {
277
    /// Creates a pager for `total_pages` pages with `current_page` (1-based)
278
    /// selected. `current_page` is clamped into `[1, total_pages.max(1)]`.
279
489
    #[must_use] pub fn create(current_page: usize, total_pages: usize) -> Self {
280
489
        let total_pages = total_pages.max(1);
281
489
        let current_page = current_page.clamp(1, total_pages);
282
489
        Self {
283
489
            pagination_state: PaginationStateWrapper {
284
489
                inner: PaginationState {
285
489
                    current_page,
286
489
                    total_pages,
287
489
                },
288
489
                ..Default::default()
289
489
            },
290
489
            container_style: CssPropertyWithConditionsVec::from_const_slice(
291
489
                PAGINATION_CONTAINER_STYLE,
292
489
            ),
293
489
        }
294
489
    }
295

            
296
    /// Sets the current (1-based) page, clamped into `[1, total_pages]`.
297
    #[inline]
298
36
    pub fn set_current_page(&mut self, current_page: usize) {
299
36
        let total = self.pagination_state.inner.total_pages.max(1);
300
36
        self.pagination_state.inner.current_page = current_page.clamp(1, total);
301
36
    }
302

            
303
    /// Builder-style setter for the current page.
304
    #[inline]
305
6
    #[must_use] pub fn with_current_page(mut self, current_page: usize) -> Self {
306
6
        self.set_current_page(current_page);
307
6
        self
308
6
    }
309

            
310
    #[inline]
311
104
    #[must_use] pub fn swap_with_default(&mut self) -> Self {
312
104
        let mut s = Self::create(1, 1);
313
104
        core::mem::swap(&mut s, self);
314
104
        s
315
104
    }
316

            
317
    #[inline]
318
8
    pub fn set_on_change<C: Into<PaginationOnChangeCallback>>(
319
8
        &mut self,
320
8
        data: RefAny,
321
8
        on_change: C,
322
8
    ) {
323
8
        self.pagination_state.on_change = Some(PaginationOnChange {
324
8
            callback: on_change.into(),
325
8
            refany: data,
326
8
        })
327
8
        .into();
328
8
    }
329

            
330
    #[inline]
331
6
    #[must_use] pub fn with_on_change<C: Into<PaginationOnChangeCallback>>(
332
6
        mut self,
333
6
        data: RefAny,
334
6
        on_change: C,
335
6
    ) -> Self {
336
6
        self.set_on_change(data, on_change);
337
6
        self
338
6
    }
339

            
340
75
    #[must_use] pub fn dom(self) -> Dom {
341
        use azul_core::{
342
            callbacks::CoreCallback,
343
            dom::{EventFilter, HoverEventFilter},
344
            refany::OptionRefAny,
345
        };
346

            
347
75
        let current = self.pagination_state.inner.current_page;
348
75
        let total = self.pagination_state.inner.total_pages;
349

            
350
        // One shared RefAny across every button's callback (RefAny::clone shares
351
        // the underlying state — same pattern as segmented/tabs/map).
352
75
        let state = RefAny::new(self.pagination_state);
353

            
354
75
        let make_button =
355
2455
            |label: AzString, class: &'static [IdOrClass], style: CssPropertyWithConditionsVec| {
356
2455
                Dom::create_p_with_text(label)
357
2455
                    .with_ids_and_classes(IdOrClassVec::from_const_slice(class))
358
2455
                    .with_css_props(style)
359
2455
                    .with_callbacks(
360
2455
                        vec![CoreCallbackData {
361
2455
                            event: EventFilter::Hover(HoverEventFilter::MouseUp),
362
2455
                            callback: CoreCallback {
363
2455
                                cb: on_page_click as usize,
364
2455
                                ctx: OptionRefAny::None,
365
2455
                            },
366
2455
                            refany: state.clone(),
367
2455
                        }]
368
2455
                        .into(),
369
                    )
370
2455
                    .with_tab_index(TabIndex::Auto)
371
2455
            };
372

            
373
75
        let mut children: Vec<Dom> = Vec::with_capacity(total.saturating_add(2));
374

            
375
        // Prev (first, left-rounded; disabled-look at page 1).
376
75
        children.push(make_button(
377
75
            PREV_LABEL,
378
75
            PAGINATION_NAV_CLASS,
379
75
            build_button_style(false, current <= 1, true, false),
380
75
        ));
381

            
382
        // Page-number buttons 1..=total.
383
2305
        for page in 1..=total {
384
2305
            children.push(make_button(
385
2305
                AzString::from(format!("{page}").as_str()),
386
2305
                PAGINATION_PAGE_CLASS,
387
2305
                build_button_style(page == current, false, false, false),
388
2305
            ));
389
2305
        }
390

            
391
        // Next (last, right-rounded; disabled-look at the final page).
392
75
        children.push(make_button(
393
75
            NEXT_LABEL,
394
75
            PAGINATION_NAV_CLASS,
395
75
            build_button_style(false, current >= total, false, true),
396
75
        ));
397

            
398
75
        Dom::create_div()
399
75
            .with_ids_and_classes(IdOrClassVec::from_const_slice(PAGINATION_CLASS))
400
75
            .with_css_props(self.container_style)
401
75
            .with_children(children.into())
402
75
    }
403
}
404

            
405
impl Default for Pagination {
406
203
    fn default() -> Self {
407
203
        Self::create(1, 1)
408
203
    }
409
}
410

            
411
/// Click handler shared by all buttons. Resolves the clicked button from its
412
/// sibling position (`Prev`=0, page `p`=`p`, `Next`=last), computes the new page
413
/// within bounds, and — only if it actually changed — updates the state, invokes
414
/// the user callback, and live-restyles every button.
415
52
extern "C" fn on_page_click(mut data: RefAny, mut info: CallbackInfo) -> Update {
416
    use azul_core::dom::DomNodeId;
417

            
418
52
    let clicked = info.get_hit_node();
419
52
    let Some(parent) = info.get_parent(clicked) else {
420
3
        return Update::DoNothing;
421
    };
422

            
423
    // Collect the buttons in document order: [Prev, page1 … pageN, Next].
424
49
    let mut buttons: Vec<DomNodeId> = Vec::new();
425
49
    let mut cur = info.get_first_child(parent);
426
373
    while let Some(node) = cur {
427
324
        buttons.push(node);
428
324
        cur = info.get_next_sibling(node);
429
324
    }
430
49
    let n = buttons.len();
431
49
    if n < 2 {
432
1
        return Update::DoNothing;
433
48
    }
434
    // Page buttons occupy positions 1..=total; Prev=0, Next=n-1.
435
48
    let total = n - 2;
436

            
437
197
    let Some(pos) = buttons.iter().position(|b| *b == clicked) else {
438
        return Update::DoNothing;
439
    };
440

            
441
46
    let current = {
442
48
        let Some(pg) = data.downcast_ref::<PaginationStateWrapper>() else {
443
2
            return Update::DoNothing;
444
        };
445
46
        pg.inner.current_page
446
    };
447

            
448
46
    let new_page = if pos == 0 {
449
        // Prev
450
14
        if current > 1 {
451
8
            current - 1
452
        } else {
453
6
            current
454
        }
455
32
    } else if pos == n - 1 {
456
        // Next
457
18
        if current < total {
458
8
            current + 1
459
        } else {
460
10
            current
461
        }
462
    } else {
463
        // A page-number button: its 1-based page equals its sibling position.
464
14
        pos
465
    };
466

            
467
46
    if new_page == current {
468
        // Clicked the current page, or a disabled Prev/Next at a bound.
469
19
        return Update::DoNothing;
470
27
    }
471

            
472
27
    let result = {
473
27
        let Some(mut pg) = data.downcast_mut::<PaginationStateWrapper>() else {
474
            return Update::DoNothing;
475
        };
476
27
        pg.inner.current_page = new_page;
477
27
        let inner = pg.inner;
478
27
        let pg = &mut *pg;
479
27
        match pg.on_change.as_mut() {
480
4
            Some(PaginationOnChange { callback, refany }) => {
481
4
                (callback.cb)(refany.clone(), info, inner)
482
            }
483
23
            None => Update::DoNothing,
484
        }
485
    };
486

            
487
    // Live-restyle: active page gets the accent fill + light text; Prev/Next show
488
    // the muted disabled text at their bounds; everything else is neutral.
489
199
    for (i, node) in buttons.iter().enumerate() {
490
199
        let (bg, text) = if i == 0 {
491
            // Prev
492
27
            let disabled = new_page <= 1;
493
27
            (NEUTRAL_BG, if disabled { DISABLED_TEXT } else { NEUTRAL_TEXT })
494
172
        } else if i == n - 1 {
495
            // Next
496
27
            let disabled = new_page >= total;
497
27
            (NEUTRAL_BG, if disabled { DISABLED_TEXT } else { NEUTRAL_TEXT })
498
145
        } else if i == new_page {
499
26
            (ACCENT_BG, ACTIVE_TEXT)
500
        } else {
501
119
            (NEUTRAL_BG, NEUTRAL_TEXT)
502
        };
503
199
        info.set_css_property(*node, CssProperty::const_background_content(bg));
504
199
        info.set_css_property(*node, CssProperty::const_text_color(StyleTextColor { inner: text }));
505
    }
506

            
507
27
    result
508
52
}
509

            
510
impl From<Pagination> for Dom {
511
1
    fn from(p: Pagination) -> Self {
512
1
        p.dom()
513
1
    }
514
}
515

            
516
#[cfg(test)]
517
#[allow(clippy::float_cmp, clippy::too_many_lines)]
518
mod autotest_generated {
519
    use std::{
520
        collections::{BTreeMap, HashMap},
521
        sync::{Arc, Mutex},
522
    };
523

            
524
    use azul_core::{
525
        dom::{DomId, DomNodeId, EventFilter, HoverEventFilter, NodeId, NodeType},
526
        geom::{LogicalRect, OptionLogicalPosition},
527
        gl::OptionGlContextPtr,
528
        hit_test::ScrollPosition,
529
        refany::OptionRefAny,
530
        resources::RendererResources,
531
        styled_dom::{NodeHierarchyItemId, StyledDom},
532
        window::{MonitorVec, RawWindowHandle},
533
    };
534
    use azul_css::{
535
        props::{
536
            basic::{length::SizeMetric, pixel::PixelValue},
537
            property::CssPropertyType,
538
        },
539
        system::SystemStyle,
540
    };
541
    use rust_fontconfig::FcFontCache;
542

            
543
    use super::*;
544
    #[cfg(feature = "icu")]
545
    use crate::icu::IcuLocalizerHandle;
546
    use crate::{
547
        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
548
        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
549
        window::{DomLayoutResult, LayoutWindow},
550
        window_state::FullWindowState,
551
    };
552

            
553
    // ------------------------------------------------------------------
554
    // Helpers
555
    // ------------------------------------------------------------------
556

            
557
    /// The declared properties of a style vec, in declaration order.
558
    fn declared(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
559
        v.as_ref().iter().map(|p| p.property.clone()).collect()
560
    }
561

            
562
    /// The declared properties of a rendered node's inline style, in declaration
563
    /// order (`with_css_props` folds the vec into a `Css`; this reads it back).
564
    fn inline_props(node: &Dom) -> Vec<CssProperty> {
565
        node.root
566
            .style
567
            .iter_inline_properties()
568
            .map(|(p, _)| p.clone())
569
            .collect()
570
    }
571

            
572
    fn text_of(node: &Dom) -> Option<&str> {
573
        match node.root.get_node_type() {
574
            NodeType::Text(s) => Some(s.as_ref().as_str()),
575
            NodeType::P => match node.children.as_ref() {
576
                [only] => match only.root.get_node_type() {
577
                    NodeType::Text(s) => Some(s.as_ref().as_str()),
578
                    _ => None,
579
                },
580
                _ => None,
581
            },
582
            _ => None,
583
        }
584
    }
585

            
586
    fn classes(node: &Dom) -> Vec<String> {
587
        node.root
588
            .get_ids_and_classes()
589
            .as_ref()
590
            .iter()
591
            .filter_map(|c| match c {
592
                Class(s) => Some(s.as_str().to_string()),
593
                IdOrClass::Id(_) => None,
594
            })
595
            .collect()
596
    }
597

            
598
    /// The one text colour a button declares (asserting there is at most one — a
599
    /// second declaration would silently shadow the first at cascade time).
600
    fn text_color(props: &[CssProperty]) -> Option<ColorU> {
601
        let found: Vec<ColorU> = props
602
            .iter()
603
            .filter_map(|p| match p {
604
                CssProperty::TextColor(v) => v.get_property().map(|c| c.inner),
605
                _ => None,
606
            })
607
            .collect();
608
        assert!(found.len() <= 1, "a button must declare at most one text colour");
609
        found.first().copied()
610
    }
611

            
612
    /// The one flat background colour a button declares.
613
    fn background_color(props: &[CssProperty]) -> Option<ColorU> {
614
        let found: Vec<&StyleBackgroundContentVec> = props
615
            .iter()
616
            .filter_map(|p| match p {
617
                CssProperty::BackgroundContent(v) => v.get_property(),
618
                _ => None,
619
            })
620
            .collect();
621
        assert!(found.len() <= 1, "a button must declare at most one background");
622
        let bg: &StyleBackgroundContentVec = *found.first()?;
623
        assert_eq!(bg.as_ref().len(), 1, "a button must declare exactly one background layer");
624
        match &bg.as_ref()[0] {
625
            StyleBackgroundContent::Color(c) => Some(*c),
626
            other => panic!("pagination background is not a flat colour: {other:?}"),
627
        }
628
    }
629

            
630
    /// The `f32` of a `PixelValue`, asserting the length is an absolute `px`. An
631
    /// `em`/`%` slipping into the button chrome would resolve against the parent
632
    /// font/box instead of the intended fixed padding, border or radius.
633
    fn px(pv: &PixelValue) -> f32 {
634
        assert_eq!(
635
            pv.metric,
636
            SizeMetric::Px,
637
            "pagination geometry must be absolute px, got {:?}",
638
            pv.metric
639
        );
640
        pv.number.get()
641
    }
642

            
643
    /// Every length a style declares (min-width, paddings, border widths, font
644
    /// size, corner radii).
645
    fn pixel_values(props: &[CssProperty]) -> Vec<PixelValue> {
646
        props
647
            .iter()
648
            .filter_map(|p| match p {
649
                CssProperty::MinWidth(v) => v.get_property().map(|x| x.inner),
650
                CssProperty::PaddingTop(v) => v.get_property().map(|x| x.inner),
651
                CssProperty::PaddingBottom(v) => v.get_property().map(|x| x.inner),
652
                CssProperty::PaddingLeft(v) => v.get_property().map(|x| x.inner),
653
                CssProperty::PaddingRight(v) => v.get_property().map(|x| x.inner),
654
                CssProperty::BorderTopWidth(v) => v.get_property().map(|x| x.inner),
655
                CssProperty::BorderBottomWidth(v) => v.get_property().map(|x| x.inner),
656
                CssProperty::BorderLeftWidth(v) => v.get_property().map(|x| x.inner),
657
                CssProperty::BorderRightWidth(v) => v.get_property().map(|x| x.inner),
658
                CssProperty::FontSize(v) => v.get_property().map(|x| x.inner),
659
                CssProperty::BorderTopLeftRadius(v) => v.get_property().map(|x| x.inner),
660
                CssProperty::BorderBottomLeftRadius(v) => v.get_property().map(|x| x.inner),
661
                CssProperty::BorderTopRightRadius(v) => v.get_property().map(|x| x.inner),
662
                CssProperty::BorderBottomRightRadius(v) => v.get_property().map(|x| x.inner),
663
                _ => None,
664
            })
665
            .collect()
666
    }
667

            
668
    /// The four corner radii a style declares, as `(top-left, bottom-left,
669
    /// top-right, bottom-right)`; `None` where the corner is left square.
670
    fn radii(props: &[CssProperty]) -> (Option<f32>, Option<f32>, Option<f32>, Option<f32>) {
671
        let find = |f: &dyn Fn(&CssProperty) -> Option<f32>| props.iter().find_map(f);
672
        (
673
            find(&|p| match p {
674
                CssProperty::BorderTopLeftRadius(v) => v.get_property().map(|r| px(&r.inner)),
675
                _ => None,
676
            }),
677
            find(&|p| match p {
678
                CssProperty::BorderBottomLeftRadius(v) => v.get_property().map(|r| px(&r.inner)),
679
                _ => None,
680
            }),
681
            find(&|p| match p {
682
                CssProperty::BorderTopRightRadius(v) => v.get_property().map(|r| px(&r.inner)),
683
                _ => None,
684
            }),
685
            find(&|p| match p {
686
                CssProperty::BorderBottomRightRadius(v) => v.get_property().map(|r| px(&r.inner)),
687
                _ => None,
688
            }),
689
        )
690
    }
691

            
692
    /// Whether the style declares a *left* border on all three of width/style/colour
693
    /// — the "I am the first button in the joined bar" marker.
694
    fn has_left_border(props: &[CssProperty]) -> (bool, bool, bool) {
695
        (
696
            props.iter().any(|p| matches!(p, CssProperty::BorderLeftWidth(_))),
697
            props.iter().any(|p| matches!(p, CssProperty::BorderLeftStyle(_))),
698
            props.iter().any(|p| matches!(p, CssProperty::BorderLeftColor(_))),
699
        )
700
    }
701

            
702
    /// Every `(active, disabled, is_first, is_last)` the builder can be handed.
703
    fn all_flag_combinations() -> Vec<(bool, bool, bool, bool)> {
704
        let mut out = Vec::with_capacity(16);
705
        for a in [false, true] {
706
            for d in [false, true] {
707
                for f in [false, true] {
708
                    for l in [false, true] {
709
                        out.push((a, d, f, l));
710
                    }
711
                }
712
            }
713
        }
714
        out
715
    }
716

            
717
    /// A `RefAny` payload recording every state a user `on_change` observes.
718
    struct ChangeLog {
719
        seen: Vec<PaginationState>,
720
    }
721

            
722
    extern "C" fn record_change(
723
        mut data: RefAny,
724
        _: CallbackInfo,
725
        state: PaginationState,
726
    ) -> Update {
727
        if let Some(mut log) = data.downcast_mut::<ChangeLog>() {
728
            log.seen.push(state);
729
        }
730
        Update::RefreshDom
731
    }
732

            
733
    extern "C" fn change_do_nothing(_: RefAny, _: CallbackInfo, _: PaginationState) -> Update {
734
        Update::DoNothing
735
    }
736

            
737
    extern "C" fn change_refresh_all(_: RefAny, _: CallbackInfo, _: PaginationState) -> Update {
738
        Update::RefreshDomAllWindows
739
    }
740

            
741
    /// Forces the `fn`-item -> `fn`-pointer coercion the `Into` bound needs.
742
    fn cb(f: PaginationOnChangeCallbackType) -> PaginationOnChangeCallback {
743
        f.into()
744
    }
745

            
746
    fn logged(data: &mut RefAny) -> Vec<PaginationState> {
747
        data.downcast_ref::<ChangeLog>()
748
            .expect("payload must still be a ChangeLog")
749
            .seen
750
            .clone()
751
    }
752

            
753
    fn current_page_of(data: &mut RefAny) -> usize {
754
        data.downcast_ref::<PaginationStateWrapper>()
755
            .expect("payload must still be a PaginationStateWrapper")
756
            .inner
757
            .current_page
758
    }
759

            
760
    /// The shared state `RefAny` carried by button `i`'s click callback.
761
    fn button_state(dom: &Dom, i: usize) -> RefAny {
762
        dom.children.as_ref()[i]
763
            .root
764
            .get_callbacks()
765
            .as_ref()
766
            .first()
767
            .expect("every pagination button carries a click callback")
768
            .refany
769
            .clone()
770
    }
771

            
772
    /// Flattened node ids. Every button is a `<p>` wrapping one bare text node,
773
    /// so depth-first pre-order lays them out as
774
    /// `0 root / 1 Prev <p> / 2 Prev text / 3 page1 <p> / 4 page1 text / …`.
775
    /// The callbacks sit on the `<p>`s.
776
    const PREV_NODE: usize = 1;
777
    /// Flattened node id of page `p` (1-based).
778
    const fn page_node(p: usize) -> usize {
779
        2 * p + 1
780
    }
781
    /// Flattened node id of the `Next` button for a `total`-page pager.
782
    const fn next_node(total: usize) -> usize {
783
        2 * total + 3
784
    }
785

            
786
    /// A `DomLayoutResult` with an *empty* layout tree: `on_page_click` only walks
787
    /// `styled_dom.node_hierarchy`, so no real layout (and no font) is needed.
788
    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
789
        DomLayoutResult {
790
            styled_dom,
791
            layout_tree: LayoutTree {
792
                nodes: Vec::new(),
793
                warm: Vec::new(),
794
                cold: Vec::new(),
795
                root: 0,
796
                dom_to_layout: BTreeMap::new(),
797
                children_arena: Vec::new(),
798
                children_offsets: Vec::new(),
799
                subtree_needs_intrinsic: Vec::new(),
800
            },
801
            calculated_positions: Vec::new(),
802
            viewport: LogicalRect::zero(),
803
            display_list: Arc::new(DisplayList::default()),
804
            scroll_ids: HashMap::new(),
805
            scroll_id_to_node_id: HashMap::new(),
806
        }
807
    }
808

            
809
    /// Flattens `p.dom()` and hands back the shared state `RefAny` its buttons carry.
810
    fn flatten(p: Pagination) -> (StyledDom, RefAny) {
811
        let dom = p.dom();
812
        let state = button_state(&dom, 0);
813
        (StyledDom::create_from_dom(dom), state)
814
    }
815

            
816
    /// Invokes `on_page_click` against a `LayoutWindow` holding `styled` (or nothing
817
    /// at all, when `styled` is `None`), with flattened node `hit` as the hit node.
818
    /// Returns the `Update` plus every recorded `CallbackChange`.
819
    fn run_click(
820
        styled: Option<StyledDom>,
821
        hit: usize,
822
        data: RefAny,
823
    ) -> (Update, Vec<CallbackChange>) {
824
        let mut layout_window =
825
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
826
        if let Some(sd) = styled {
827
            layout_window
828
                .layout_results
829
                .insert(DomId::ROOT_ID, layout_result(sd));
830
        }
831

            
832
        let renderer_resources = RendererResources::default();
833
        let previous_window_state: Option<FullWindowState> = None;
834
        let current_window_state = FullWindowState::default();
835
        let gl_context = OptionGlContextPtr::None;
836
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
837
            BTreeMap::new();
838
        let window_handle = RawWindowHandle::Unsupported;
839
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
840

            
841
        let ref_data = CallbackInfoRefData {
842
            layout_window: &layout_window,
843
            renderer_resources: &renderer_resources,
844
            previous_window_state: &previous_window_state,
845
            current_window_state: &current_window_state,
846
            gl_context: &gl_context,
847
            current_scroll_manager: &scroll_states,
848
            current_window_handle: &window_handle,
849
            system_callbacks: &system_callbacks,
850
            system_style: Arc::new(SystemStyle::default()),
851
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
852
            #[cfg(feature = "icu")]
853
            icu_localizer: IcuLocalizerHandle::default(),
854
            ctx: OptionRefAny::None,
855
        };
856

            
857
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
858

            
859
        let info = CallbackInfo::new(
860
            &ref_data,
861
            &changes,
862
            DomNodeId {
863
                dom: DomId::ROOT_ID,
864
                node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(hit))),
865
            },
866
            OptionLogicalPosition::None,
867
            OptionLogicalPosition::None,
868
        );
869

            
870
        let update = on_page_click(data, info);
871
        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
872
        (update, recorded)
873
    }
874

            
875
    /// Decodes a live-restyle transaction into `(node index, background, text)`
876
    /// triples — the handler pushes exactly one background and one colour write per
877
    /// button, in that order.
878
    fn restyle(changes: &[CallbackChange]) -> Vec<(usize, ColorU, ColorU)> {
879
        assert_eq!(
880
            changes.len() % 2,
881
            0,
882
            "a restyle pass writes background+colour in pairs"
883
        );
884
        let decode = |c: &CallbackChange| -> (usize, CssProperty) {
885
            match c {
886
                CallbackChange::ChangeNodeCssProperties {
887
                    dom_id,
888
                    node_id,
889
                    properties,
890
                } => {
891
                    assert_eq!(*dom_id, DomId::ROOT_ID, "restyle must stay in the root DOM");
892
                    assert_eq!(
893
                        properties.as_ref().len(),
894
                        1,
895
                        "set_css_property writes exactly one property per change"
896
                    );
897
                    (node_id.index(), properties.as_ref()[0].clone())
898
                }
899
                other => panic!("unexpected change pushed by on_page_click: {other:?}"),
900
            }
901
        };
902
        changes
903
            .chunks(2)
904
            .map(|pair| {
905
                let (bg_node, bg_prop) = decode(&pair[0]);
906
                let (fg_node, fg_prop) = decode(&pair[1]);
907
                assert_eq!(bg_node, fg_node, "both writes must target the same button");
908
                let bg = background_color(&[bg_prop])
909
                    .expect("the first write of each pair is the background");
910
                let fg =
911
                    text_color(&[fg_prop]).expect("the second write of each pair is the colour");
912
                (bg_node, bg, fg)
913
            })
914
            .collect()
915
    }
916

            
917
    // ==================================================================
918
    // build_button_style
919
    // ==================================================================
920

            
921
    #[test]
922
    fn build_button_style_never_panics_and_is_deterministic() {
923
        for (a, d, f, l) in all_flag_combinations() {
924
            let style = build_button_style(a, d, f, l);
925
            assert!(
926
                !style.as_ref().is_empty(),
927
                "({a},{d},{f},{l}) produced an empty style"
928
            );
929
            assert_eq!(
930
                declared(&style),
931
                declared(&build_button_style(a, d, f, l)),
932
                "({a},{d},{f},{l}) is not a pure function of its flags"
933
            );
934
        }
935
    }
936

            
937
    #[test]
938
    fn build_button_style_declares_every_property_at_most_once() {
939
        // A duplicate declaration would silently shadow the earlier one at cascade
940
        // time, making the widget's look depend on declaration order.
941
        for (a, d, f, l) in all_flag_combinations() {
942
            let props = declared(&build_button_style(a, d, f, l));
943
            let mut types: Vec<CssPropertyType> = props.iter().map(CssProperty::get_type).collect();
944
            let len = types.len();
945
            types.sort_unstable();
946
            types.dedup();
947
            assert_eq!(types.len(), len, "duplicate declaration for ({a},{d},{f},{l})");
948
        }
949
    }
950

            
951
    #[test]
952
    fn build_button_style_is_unconditional() {
953
        // Every declaration is `simple` — a stray `apply_if` would make the button
954
        // silently lose its fill under some pseudo-state.
955
        for (a, d, f, l) in all_flag_combinations() {
956
            for p in build_button_style(a, d, f, l).as_ref() {
957
                assert!(
958
                    p.apply_if.as_ref().is_empty(),
959
                    "({a},{d},{f},{l}) declared a conditional property: {:?}",
960
                    p.property
961
                );
962
            }
963
        }
964
    }
965

            
966
    #[test]
967
    fn build_button_style_active_wins_over_disabled_for_the_text_colour() {
968
        // `active` is checked first, so an active-*and*-disabled button reads as
969
        // active; only a non-active disabled button gets the muted grey.
970
        for (flags, expected) in [
971
            ((true, true), ACTIVE_TEXT),
972
            ((true, false), ACTIVE_TEXT),
973
            ((false, true), DISABLED_TEXT),
974
            ((false, false), NEUTRAL_TEXT),
975
        ] {
976
            let (a, d) = flags;
977
            let props = declared(&build_button_style(a, d, false, false));
978
            assert_eq!(
979
                text_color(&props),
980
                Some(expected),
981
                "active={a} disabled={d} picked the wrong text colour"
982
            );
983
        }
984
    }
985

            
986
    #[test]
987
    fn build_button_style_paints_the_accent_fill_only_when_active() {
988
        for (a, d, f, l) in all_flag_combinations() {
989
            let props = declared(&build_button_style(a, d, f, l));
990
            let expected = if a { ACCENT_BG_COLOR } else { NEUTRAL_BG_COLOR };
991
            assert_eq!(
992
                background_color(&props),
993
                Some(expected),
994
                "({a},{d},{f},{l}) has the wrong fill — `disabled` must not tint the background"
995
            );
996
        }
997
    }
998

            
999
    #[test]
    fn build_button_style_rounds_only_the_outer_corners() {
        for (a, d, f, l) in all_flag_combinations() {
            let props = declared(&build_button_style(a, d, f, l));
            let (tl, bl, tr, br) = radii(&props);
            let r = PAGE_RADIUS as f32;
            assert_eq!(tl, if f { Some(r) } else { None }, "top-left for is_first={f}");
            assert_eq!(bl, if f { Some(r) } else { None }, "bottom-left for is_first={f}");
            assert_eq!(tr, if l { Some(r) } else { None }, "top-right for is_last={l}");
            assert_eq!(br, if l { Some(r) } else { None }, "bottom-right for is_last={l}");
        }
    }
    #[test]
    fn build_button_style_gives_only_the_first_button_a_left_border() {
        // Adjacent buttons must share a single 1px separator: everyone draws a right
        // border, only the leftmost also draws a left one.
        for (a, d, f, l) in all_flag_combinations() {
            let props = declared(&build_button_style(a, d, f, l));
            assert_eq!(
                has_left_border(&props),
                (f, f, f),
                "left border must be present iff is_first ({a},{d},{f},{l})"
            );
            assert!(
                props.iter().any(|p| matches!(p, CssProperty::BorderRightWidth(_))),
                "every button draws its own right border"
            );
        }
    }
    #[test]
    fn build_button_style_property_count_is_purely_position_dependent() {
        // The colour flags must not add or drop declarations — only the position
        // flags do (left border + 2 radii for first, 2 radii for last).
        let base = build_button_style(false, false, false, false).as_ref().len();
        for (a, d) in [(false, false), (true, false), (false, true), (true, true)] {
            assert_eq!(
                build_button_style(a, d, false, false).as_ref().len(),
                base,
                "colour flags changed the declaration count"
            );
            assert_eq!(
                build_button_style(a, d, true, false).as_ref().len(),
                base + 5,
                "is_first must add exactly left width/style/colour + 2 radii"
            );
            assert_eq!(
                build_button_style(a, d, false, true).as_ref().len(),
                base + 2,
                "is_last must add exactly 2 radii"
            );
            assert_eq!(
                build_button_style(a, d, true, true).as_ref().len(),
                base + 7,
                "a single-button bar is first *and* last"
            );
        }
    }
    #[test]
    fn build_button_style_uses_only_absolute_px_lengths() {
        for (a, d, f, l) in all_flag_combinations() {
            let props = declared(&build_button_style(a, d, f, l));
            let lengths = pixel_values(&props);
            assert!(!lengths.is_empty(), "a button must declare some geometry");
            for pv in &lengths {
                let v = px(pv); // asserts SizeMetric::Px
                assert!(v.is_finite(), "non-finite length {v} in ({a},{d},{f},{l})");
                assert!(v >= 0.0, "negative length {v} in ({a},{d},{f},{l})");
            }
        }
    }
    #[test]
    fn button_palette_is_opaque_and_the_states_are_visually_distinct() {
        for (name, c) in [
            ("page border", PAGE_BORDER_COLOR),
            ("accent bg", ACCENT_BG_COLOR),
            ("neutral bg", NEUTRAL_BG_COLOR),
            ("active text", ACTIVE_TEXT),
            ("neutral text", NEUTRAL_TEXT),
            ("disabled text", DISABLED_TEXT),
        ] {
            assert_eq!(c.a, 255, "{name} must be fully opaque");
        }
        assert_ne!(ACCENT_BG_COLOR, NEUTRAL_BG_COLOR, "the active page must stand out");
        assert_ne!(ACTIVE_TEXT, NEUTRAL_TEXT, "active text must read on the accent fill");
        assert_ne!(NEUTRAL_TEXT, DISABLED_TEXT, "a disabled end must look disabled");
        // The active-page text sits on the accent fill and must not equal it.
        assert_ne!(ACTIVE_TEXT, ACCENT_BG_COLOR, "active text would be invisible");
        assert_ne!(NEUTRAL_TEXT, NEUTRAL_BG_COLOR, "neutral text would be invisible");
        assert_ne!(DISABLED_TEXT, NEUTRAL_BG_COLOR, "disabled text would be invisible");
    }
    // ==================================================================
    // Pagination::create
    // ==================================================================
    #[test]
    fn create_clamps_current_page_into_range() {
        for (cur, total, want_cur, want_total) in [
            // (input page, input total, expected page, expected total)
            (0usize, 0usize, 1usize, 1usize),
            (0, 1, 1, 1),
            (1, 0, 1, 1),
            (0, 5, 1, 5),
            (1, 5, 1, 5),
            (3, 5, 3, 5),
            (5, 5, 5, 5),
            (6, 5, 5, 5),
            (usize::MAX, 5, 5, 5),
            (usize::MAX, 1, 1, 1),
            (usize::MAX, 0, 1, 1),
            (5, usize::MAX, 5, usize::MAX),
            (0, usize::MAX, 1, usize::MAX),
            (usize::MAX, usize::MAX, usize::MAX, usize::MAX),
        ] {
            let p = Pagination::create(cur, total);
            assert_eq!(
                p.pagination_state.inner,
                PaginationState {
                    current_page: want_cur,
                    total_pages: want_total,
                },
                "create({cur}, {total})"
            );
        }
    }
    #[test]
    fn create_never_yields_a_zero_or_out_of_range_page() {
        // The 1-based invariant is what every consumer (dom(), the click handler,
        // the restyle pass) relies on; a 0 page would mean "no page is current".
        for total in [0usize, 1, 2, 3, 7, 64, 1024, usize::MAX - 1, usize::MAX] {
            for cur in [0usize, 1, 2, 63, 1023, usize::MAX - 1, usize::MAX] {
                let s = Pagination::create(cur, total).pagination_state.inner;
                assert!(s.total_pages >= 1, "create({cur}, {total}) left 0 pages");
                assert!(s.current_page >= 1, "create({cur}, {total}) produced page 0");
                assert!(
                    s.current_page <= s.total_pages,
                    "create({cur}, {total}) escaped the upper bound"
                );
                // Clamping must never *invent* a page: an in-range input survives.
                if cur >= 1 && cur <= total {
                    assert_eq!(s.current_page, cur, "an in-range page must pass through");
                }
            }
        }
    }
    #[test]
    fn create_installs_no_callback_and_the_shared_const_container_style() {
        let p = Pagination::create(2, 4);
        assert!(
            p.pagination_state.on_change.as_ref().is_none(),
            "create must not install a callback"
        );
        assert_eq!(
            p.container_style.as_ref(),
            PAGINATION_CONTAINER_STYLE,
            "create must reuse the const container style"
        );
    }
    #[test]
    fn default_equals_create_one_one() {
        assert_eq!(Pagination::default(), Pagination::create(1, 1));
        let s = Pagination::default().pagination_state.inner;
        assert_eq!(s.current_page, 1);
        assert_eq!(s.total_pages, 1);
    }
    // ==================================================================
    // Pagination::set_current_page / with_current_page
    // ==================================================================
    #[test]
    fn set_current_page_clamps_into_range() {
        for (input, want) in [
            (0usize, 1usize),
            (1, 1),
            (4, 4),
            (7, 7),
            (8, 7),
            (usize::MAX, 7),
            (usize::MAX - 1, 7),
        ] {
            let mut p = Pagination::create(1, 7);
            p.set_current_page(input);
            assert_eq!(
                p.pagination_state.inner.current_page, want,
                "set_current_page({input}) on a 7-page pager"
            );
            assert_eq!(
                p.pagination_state.inner.total_pages, 7,
                "set_current_page must not touch total_pages"
            );
        }
    }
    #[test]
    fn set_current_page_survives_a_hand_corrupted_zero_total() {
        // `total_pages` is a pub field, so it can be zeroed behind the constructor's
        // back. `clamp(1, 0)` would panic (min > max); the `.max(1)` guard is what
        // prevents that.
        let mut p = Pagination::create(1, 3);
        p.pagination_state.inner.total_pages = 0;
        for input in [0usize, 1, 5, usize::MAX] {
            p.set_current_page(input);
            assert_eq!(
                p.pagination_state.inner.current_page, 1,
                "a 0-page pager must collapse every page to 1"
            );
        }
        assert_eq!(
            p.pagination_state.inner.total_pages, 0,
            "the setter must not silently repair total_pages"
        );
    }
    #[test]
    fn set_current_page_is_idempotent_and_reversible() {
        let mut p = Pagination::create(1, 10);
        for page in [1usize, 10, 5, 5, 1, 10] {
            p.set_current_page(page);
            let once = p.pagination_state.inner.current_page;
            p.set_current_page(page);
            assert_eq!(p.pagination_state.inner.current_page, once, "not idempotent");
            assert_eq!(once, page);
        }
    }
    #[test]
    fn set_current_page_at_the_usize_max_total_does_not_overflow() {
        let mut p = Pagination::create(1, usize::MAX);
        p.set_current_page(usize::MAX);
        assert_eq!(p.pagination_state.inner.current_page, usize::MAX);
        p.set_current_page(0);
        assert_eq!(p.pagination_state.inner.current_page, 1);
    }
    #[test]
    fn with_current_page_matches_set_current_page_and_keeps_the_callback() {
        for input in [0usize, 1, 3, 9, usize::MAX] {
            let mut expected = Pagination::create(1, 6);
            expected.set_current_page(input);
            let got = Pagination::create(1, 6).with_current_page(input);
            assert_eq!(got, expected, "builder and setter must agree for {input}");
        }
        // The builder form must not drop an already-installed callback.
        let p = Pagination::create(1, 6)
            .with_on_change(RefAny::new(ChangeLog { seen: Vec::new() }), cb(record_change))
            .with_current_page(4);
        assert_eq!(p.pagination_state.inner.current_page, 4);
        assert!(
            p.pagination_state.on_change.as_ref().is_some(),
            "with_current_page must not disturb the callback"
        );
    }
    // ==================================================================
    // Pagination::swap_with_default
    // ==================================================================
    #[test]
    fn swap_with_default_returns_the_old_value_and_resets_self() {
        let mut p = Pagination::create(3, 9);
        let old = p.swap_with_default();
        assert_eq!(old.pagination_state.inner.current_page, 3);
        assert_eq!(old.pagination_state.inner.total_pages, 9);
        assert_eq!(p, Pagination::default(), "self must be left as a 1-of-1 pager");
    }
    #[test]
    fn swap_with_default_moves_the_callback_out_of_self() {
        let mut p = Pagination::create(1, 3)
            .with_on_change(RefAny::new(ChangeLog { seen: Vec::new() }), cb(record_change));
        let old = p.swap_with_default();
        assert!(
            old.pagination_state.on_change.as_ref().is_some(),
            "the callback must travel with the returned value"
        );
        assert!(
            p.pagination_state.on_change.as_ref().is_none(),
            "self must not keep a dangling reference to the moved-out callback"
        );
    }
    #[test]
    fn swap_with_default_is_stable_when_repeated() {
        let mut p = Pagination::create(2, 4);
        let _ = p.swap_with_default();
        for _ in 0..100 {
            let out = p.swap_with_default();
            assert_eq!(out, Pagination::default());
            assert_eq!(p, Pagination::default());
        }
    }
    #[test]
    fn swap_with_default_preserves_a_hand_corrupted_state_verbatim() {
        let mut p = Pagination::create(1, 4);
        p.pagination_state.inner.current_page = usize::MAX;
        p.pagination_state.inner.total_pages = 0;
        let old = p.swap_with_default();
        assert_eq!(
            old.pagination_state.inner,
            PaginationState {
                current_page: usize::MAX,
                total_pages: 0,
            },
            "the swap must move state out untouched (no clamping/rewriting)"
        );
        assert_eq!(p.pagination_state.inner.current_page, 1);
        assert_eq!(p.pagination_state.inner.total_pages, 1);
    }
    // ==================================================================
    // Pagination::set_on_change / with_on_change
    // ==================================================================
    #[test]
    fn with_on_change_installs_the_callback_and_touches_nothing_else() {
        let before = Pagination::create(2, 5);
        let after = Pagination::create(2, 5)
            .with_on_change(RefAny::new(ChangeLog { seen: Vec::new() }), cb(record_change));
        assert_eq!(
            after.pagination_state.inner, before.pagination_state.inner,
            "installing a callback must not disturb the page state"
        );
        assert_eq!(
            after.container_style, before.container_style,
            "installing a callback must not disturb the container style"
        );
        let installed = after
            .pagination_state
            .on_change
            .as_ref()
            .expect("with_on_change must install Some(..)");
        assert_eq!(installed.callback.cb as usize, record_change as usize);
    }
    #[test]
    fn set_on_change_overwrites_the_previous_callback_and_data() {
        let mut p = Pagination::create(1, 3);
        p.set_on_change(RefAny::new(ChangeLog { seen: Vec::new() }), cb(record_change));
        p.set_on_change(RefAny::new(42u32), cb(change_do_nothing));
        let installed = p
            .pagination_state
            .on_change
            .as_mut()
            .expect("still Some after the overwrite");
        assert_eq!(
            installed.callback.cb as usize, change_do_nothing as usize,
            "the last set_on_change must win"
        );
        assert_eq!(
            installed.refany.downcast_ref::<u32>().map(|v| *v),
            Some(42),
            "the payload must be replaced along with the fn pointer"
        );
        assert!(
            installed.refany.downcast_ref::<ChangeLog>().is_none(),
            "the stale payload must be gone"
        );
    }
    #[test]
    fn generic_callback_conversion_round_trips_the_fn_pointer() {
        // The FFI path (`From<Callback>`) transmutes the fn pointer. The value must
        // round-trip bit-for-bit — a corrupted pointer would be an unconditional
        // jump into garbage at click time. (Never invoked here.)
        let raw = record_change as usize;
        let generic = Callback {
            cb: unsafe { core::mem::transmute::<usize, crate::callbacks::CallbackType>(raw) },
            ctx: OptionRefAny::None,
        };
        let converted: PaginationOnChangeCallback = generic.into();
        assert_eq!(converted.cb as usize, raw);
    }
    // ==================================================================
    // Pagination::dom
    // ==================================================================
    #[test]
    fn dom_lays_out_prev_then_pages_then_next() {
        for total in [1usize, 2, 3, 10, 99] {
            let dom = Pagination::create(1, total).dom();
            let children = dom.children.as_ref();
            assert_eq!(children.len(), total + 2, "Prev + {total} pages + Next");
            assert_eq!(text_of(&children[0]), Some("Prev"));
            assert_eq!(text_of(&children[total + 1]), Some("Next"));
            for (p, child) in children.iter().enumerate().take(total + 1).skip(1) {
                assert_eq!(
                    text_of(child),
                    Some(p.to_string().as_str()),
                    "page {p} must sit at sibling position {p}"
                );
            }
            assert!(dom.root.has_class("__azul-native-pagination"));
            assert!(dom.root.is_node_type(NodeType::Div), "the bar must be a div");
            assert_eq!(classes(&children[0]), ["__azul-native-pagination-nav"]);
            assert_eq!(classes(&children[total + 1]), ["__azul-native-pagination-nav"]);
            assert_eq!(classes(&children[1]), ["__azul-native-pagination-page"]);
        }
    }
    #[test]
    fn dom_page_labels_are_plain_ascii_decimal() {
        let total = 1000;
        let dom = Pagination::create(1, total).dom();
        let children = dom.children.as_ref();
        for p in [1usize, 9, 10, 99, 100, 999, 1000] {
            let label = text_of(&children[p]).expect("page buttons are text nodes");
            assert_eq!(label, p.to_string(), "page {p} label");
            assert!(
                label.bytes().all(|b| b.is_ascii_digit()),
                "page {p} label {label:?} is not plain decimal"
            );
        }
    }
    #[test]
    fn dom_wires_one_mouseup_handler_per_button() {
        let total = 4;
        let dom = Pagination::create(2, total).dom();
        for (i, child) in dom.children.as_ref().iter().enumerate() {
            let cbs = child.root.get_callbacks();
            assert_eq!(cbs.as_ref().len(), 1, "button {i} must carry exactly one handler");
            assert_eq!(
                cbs.as_ref()[0].event,
                EventFilter::Hover(HoverEventFilter::MouseUp)
            );
            assert_eq!(cbs.as_ref()[0].callback.cb, on_page_click as usize);
            assert_eq!(
                child.root.get_tab_index(),
                Some(TabIndex::Auto),
                "button {i} must be keyboard-reachable"
            );
        }
        assert!(
            dom.root.get_callbacks().as_ref().is_empty(),
            "the container itself must not be clickable"
        );
    }
    #[test]
    fn dom_shares_one_state_refany_across_every_button() {
        let dom = Pagination::create(1, 4).dom();
        // Write through Prev's handle…
        let mut first = button_state(&dom, 0);
        {
            let mut w = first
                .downcast_mut::<PaginationStateWrapper>()
                .expect("button state must be a PaginationStateWrapper");
            w.inner.current_page = 3;
        }
        // …and read it back through Next's handle.
        let mut last = button_state(&dom, 5);
        assert_eq!(
            current_page_of(&mut last),
            3,
            "every button must observe the same shared state"
        );
    }
    #[test]
    fn dom_gives_separate_pagers_separate_state() {
        let a = Pagination::create(1, 3).dom();
        let b = Pagination::create(1, 3).dom();
        let mut a0 = button_state(&a, 0);
        {
            let mut w = a0.downcast_mut::<PaginationStateWrapper>().unwrap();
            w.inner.current_page = 3;
        }
        let mut b0 = button_state(&b, 0);
        assert_eq!(
            current_page_of(&mut b0),
            1,
            "two pagers must not alias one another's state"
        );
    }
    #[test]
    fn dom_marks_exactly_the_current_page_active() {
        for total in [1usize, 2, 5, 12] {
            for current in 1..=total {
                let dom = Pagination::create(current, total).dom();
                let children = dom.children.as_ref();
                let active: Vec<usize> = (0..children.len())
                    .filter(|i| {
                        background_color(&inline_props(&children[*i])) == Some(ACCENT_BG_COLOR)
                    })
                    .collect();
                assert_eq!(
                    active,
                    vec![current],
                    "exactly the current page carries the accent fill (total={total})"
                );
                assert_eq!(
                    text_color(&inline_props(&children[current])),
                    Some(ACTIVE_TEXT),
                    "the active page must use the light text"
                );
            }
        }
    }
    #[test]
    fn dom_mutes_prev_at_the_first_page_and_next_at_the_last() {
        let total = 5;
        for current in 1..=total {
            let dom = Pagination::create(current, total).dom();
            let children = dom.children.as_ref();
            let prev = text_color(&inline_props(&children[0]));
            let next = text_color(&inline_props(&children[total + 1]));
            assert_eq!(
                prev,
                Some(if current == 1 { DISABLED_TEXT } else { NEUTRAL_TEXT }),
                "Prev at page {current}/{total}"
            );
            assert_eq!(
                next,
                Some(if current == total { DISABLED_TEXT } else { NEUTRAL_TEXT }),
                "Next at page {current}/{total}"
            );
            // A muted end is a *style-only* signal — it stays clickable.
            assert_eq!(children[0].root.get_callbacks().as_ref().len(), 1);
            assert_eq!(children[total + 1].root.get_callbacks().as_ref().len(), 1);
        }
    }
    #[test]
    fn dom_rounds_only_the_two_outer_ends_of_the_bar() {
        let total = 4;
        let dom = Pagination::create(1, total).dom();
        let children = dom.children.as_ref();
        let r = PAGE_RADIUS as f32;
        assert_eq!(
            radii(&inline_props(&children[0])),
            (Some(r), Some(r), None, None),
            "Prev is rounded on the left only"
        );
        assert_eq!(
            radii(&inline_props(&children[total + 1])),
            (None, None, Some(r), Some(r)),
            "Next is rounded on the right only"
        );
        for (p, child) in children.iter().enumerate().take(total + 1).skip(1) {
            assert_eq!(
                radii(&inline_props(child)),
                (None, None, None, None),
                "interior page {p} must stay square"
            );
            assert_eq!(
                has_left_border(&inline_props(child)),
                (false, false, false),
                "only Prev draws a left border, so buttons share one separator"
            );
        }
        assert_eq!(
            has_left_border(&inline_props(&children[0])),
            (true, true, true),
            "Prev closes the left edge of the bar"
        );
    }
    #[test]
    fn dom_carries_the_container_style_and_the_button_styles_verbatim() {
        let p = Pagination::create(2, 3);
        let dom = p.dom();
        assert_eq!(
            inline_props(&dom),
            PAGINATION_CONTAINER_STYLE
                .iter()
                .map(|p| p.property.clone())
                .collect::<Vec<_>>(),
            "the container style must survive the Dom round-trip"
        );
        let children = dom.children.as_ref();
        assert_eq!(
            inline_props(&children[2]),
            declared(&build_button_style(true, false, false, false)),
            "the active page's style must match the builder output verbatim"
        );
        assert_eq!(
            inline_props(&children[0]),
            declared(&build_button_style(false, false, true, false)),
            "Prev's style must match the builder output verbatim"
        );
        // page 2 of 3, so Next is *not* at its bound and stays un-muted.
        assert_eq!(
            inline_props(&children[4]),
            declared(&build_button_style(false, false, false, true)),
            "Next's style must match the builder output verbatim"
        );
    }
    #[test]
    fn dom_estimated_total_children_matches_the_real_child_count() {
        // `estimated_total_children` is a cached count; if it under-counts,
        // `convert_dom_into_compact_dom` under-allocates.
        for total in [1usize, 2, 3, 8, 64, 257] {
            let dom = Pagination::create(1, total).dom();
            assert_eq!(dom.children.as_ref().len(), total + 2);
            assert_eq!(
                dom.estimated_total_children,
                2 * (total + 2),
                "cached descendant count desynced for total={total}"
            );
        }
    }
    #[test]
    fn dom_of_a_hand_zeroed_total_has_only_prev_and_next() {
        // `total_pages` is a pub field: zeroing it must yield an inert two-button
        // bar, not an empty/negative range or a panic.
        let mut p = Pagination::create(1, 3);
        p.pagination_state.inner.total_pages = 0;
        let dom = p.dom();
        let children = dom.children.as_ref();
        assert_eq!(children.len(), 2, "no pages => just Prev and Next");
        assert_eq!(text_of(&children[0]), Some("Prev"));
        assert_eq!(text_of(&children[1]), Some("Next"));
        // current(1) >= total(0), so Next reads as disabled too.
        assert_eq!(text_color(&inline_props(&children[1])), Some(DISABLED_TEXT));
    }
    #[test]
    fn dom_of_an_out_of_range_current_page_marks_nothing_active() {
        // Reachable only by writing the pub field directly. The bar must still
        // render deterministically instead of indexing out of bounds.
        let mut p = Pagination::create(1, 4);
        p.pagination_state.inner.current_page = 99;
        let dom = p.dom();
        let children = dom.children.as_ref();
        assert_eq!(children.len(), 6);
        assert!(
            (0..children.len())
                .all(|i| background_color(&inline_props(&children[i])) == Some(NEUTRAL_BG_COLOR)),
            "no page matches 99, so nothing is painted active"
        );
        assert_eq!(
            text_color(&inline_props(&children[0])),
            Some(NEUTRAL_TEXT),
            "Prev is live (99 > 1)"
        );
        assert_eq!(
            text_color(&inline_props(&children[5])),
            Some(DISABLED_TEXT),
            "Next is muted (99 >= 4)"
        );
    }
    #[test]
    fn dom_of_many_pages_flattens_without_panicking() {
        let total = 500;
        let styled = StyledDom::create_from_dom(Pagination::create(250, total).dom());
        assert_eq!(
            styled.node_hierarchy.as_ref().len(),
            2 * total + 5,
            "root + (Prev + {total} pages + Next), each a <p> wrapping one text node"
        );
    }
    #[test]
    fn from_pagination_for_dom_equals_dom() {
        let dom: Dom = Pagination::create(2, 3).into();
        assert_eq!(dom.children.as_ref().len(), 5);
        assert_eq!(text_of(&dom.children.as_ref()[0]), Some("Prev"));
    }
    // ==================================================================
    // on_page_click
    // ==================================================================
    #[test]
    fn click_next_advances_exactly_one_page() {
        let total = 5;
        let (styled, state) = flatten(Pagination::create(2, total));
        let mut state2 = state.clone();
        let (update, changes) = run_click(Some(styled), next_node(total), state);
        assert_eq!(update, Update::DoNothing, "no user callback => nothing to redraw");
        assert_eq!(current_page_of(&mut state2), 3, "Next must step 2 -> 3");
        assert_eq!(
            restyle(&changes).len(),
            total + 2,
            "every button is restyled after a real change"
        );
    }
    #[test]
    fn click_prev_steps_back_exactly_one_page() {
        let total = 5;
        let (styled, state) = flatten(Pagination::create(4, total));
        let mut state2 = state.clone();
        let (_, _) = run_click(Some(styled), PREV_NODE, state);
        assert_eq!(current_page_of(&mut state2), 3, "Prev must step 4 -> 3");
    }
    #[test]
    fn click_a_page_number_jumps_straight_to_it() {
        let total = 8;
        for target in [1usize, 2, 7, 8] {
            let (styled, state) = flatten(Pagination::create(4, total));
            let mut state2 = state.clone();
            let (_, changes) = run_click(Some(styled), page_node(target), state);
            assert_eq!(
                current_page_of(&mut state2),
                target,
                "page button {target} sits at sibling position {target}"
            );
            assert!(!changes.is_empty(), "a real jump must restyle the bar");
        }
    }
    #[test]
    fn click_the_current_page_is_a_no_op() {
        let total = 6;
        let (styled, state) = flatten(Pagination::create(3, total));
        let mut state2 = state.clone();
        let (update, changes) = run_click(Some(styled), page_node(3), state);
        assert_eq!(update, Update::DoNothing);
        assert!(
            changes.is_empty(),
            "an unchanged page must not push a restyle transaction"
        );
        assert_eq!(current_page_of(&mut state2), 3);
    }
    #[test]
    fn click_prev_at_the_first_page_is_a_no_op() {
        let total = 4;
        let (styled, state) = flatten(Pagination::create(1, total));
        let mut state2 = state.clone();
        let (update, changes) = run_click(Some(styled), PREV_NODE, state);
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty(), "a disabled end must fire nothing at all");
        assert_eq!(current_page_of(&mut state2), 1, "page 1 must not underflow to 0");
    }
    #[test]
    fn click_next_at_the_last_page_is_a_no_op() {
        let total = 4;
        let (styled, state) = flatten(Pagination::create(total, total));
        let mut state2 = state.clone();
        let (update, changes) = run_click(Some(styled), next_node(total), state);
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert_eq!(current_page_of(&mut state2), total, "must not run past the end");
    }
    #[test]
    fn click_walks_the_whole_range_without_escaping_its_bounds() {
        let total = 6;
        let (styled, state) = flatten(Pagination::create(1, total));
        let mut probe = state.clone();
        // Press Next more often than there are pages.
        for step in 0..(total + 2) {
            let (_, _) = run_click(Some(styled.clone()), next_node(total), state.clone());
            let page = current_page_of(&mut probe);
            assert!(
                (1..=total).contains(&page),
                "page {page} escaped [1, {total}] after {step} Next presses"
            );
            assert_eq!(page, (step + 2).min(total), "Next must advance one at a time");
        }
        assert_eq!(current_page_of(&mut probe), total);
        // …then all the way back down.
        for step in 0..(total + 2) {
            let (_, _) = run_click(Some(styled.clone()), PREV_NODE, state.clone());
            let page = current_page_of(&mut probe);
            assert!((1..=total).contains(&page), "page {page} escaped [1, {total}]");
            assert_eq!(page, total.saturating_sub(step + 1).max(1));
        }
        assert_eq!(current_page_of(&mut probe), 1);
    }
    #[test]
    fn click_on_a_one_page_pager_is_completely_inert() {
        let (styled, state) = flatten(Pagination::create(1, 1));
        let mut probe = state.clone();
        for hit in [PREV_NODE, page_node(1), next_node(1)] {
            let (update, changes) = run_click(Some(styled.clone()), hit, state.clone());
            assert_eq!(update, Update::DoNothing, "node {hit} on a 1-page pager");
            assert!(changes.is_empty(), "node {hit} must not restyle anything");
            assert_eq!(current_page_of(&mut probe), 1);
        }
    }
    #[test]
    fn click_on_a_hand_zeroed_pager_is_inert() {
        // total_pages = 0 => children are just [Prev, Next] (n == 2, total == 0).
        // The `n < 2` guard is not hit, so both ends must fall through the bounds
        // checks instead of computing a 0/underflowing page.
        let mut p = Pagination::create(1, 3);
        p.pagination_state.inner.total_pages = 0;
        let (styled, state) = flatten(p);
        let mut probe = state.clone();
        for hit in [1usize, 2] {
            let (update, changes) = run_click(Some(styled.clone()), hit, state.clone());
            assert_eq!(update, Update::DoNothing, "node {hit} on a 0-page pager");
            assert!(changes.is_empty());
            assert_eq!(current_page_of(&mut probe), 1);
        }
    }
    #[test]
    fn click_invokes_the_user_callback_with_the_new_state() {
        let total = 5;
        let mut log = RefAny::new(ChangeLog { seen: Vec::new() });
        let p = Pagination::create(1, total).with_on_change(log.clone(), cb(record_change));
        let (styled, state) = flatten(p);
        let (update, _) = run_click(Some(styled.clone()), page_node(4), state.clone());
        assert_eq!(update, Update::RefreshDom, "the user's Update must propagate");
        assert_eq!(
            logged(&mut log),
            vec![PaginationState {
                current_page: 4,
                total_pages: total,
            }],
            "the callback sees the *new* page, with total_pages intact"
        );
        // A no-op click must not fire the callback again.
        let (update, _) = run_click(Some(styled.clone()), page_node(4), state.clone());
        assert_eq!(update, Update::DoNothing);
        assert_eq!(logged(&mut log).len(), 1, "an unchanged page fires nothing");
        let (_, _) = run_click(Some(styled), PREV_NODE, state);
        assert_eq!(
            logged(&mut log).len(),
            2,
            "a real change fires the callback again"
        );
        assert_eq!(logged(&mut log)[1].current_page, 3);
    }
    #[test]
    fn click_propagates_every_update_variant_unchanged() {
        for (callback, expected) in [
            (cb(change_do_nothing), Update::DoNothing),
            (cb(change_refresh_all), Update::RefreshDomAllWindows),
        ] {
            let p = Pagination::create(1, 3).with_on_change(RefAny::new(0u8), callback);
            let (styled, state) = flatten(p);
            let (update, _) = run_click(Some(styled), page_node(2), state);
            assert_eq!(update, expected);
        }
    }
    #[test]
    fn click_restyles_every_button_and_marks_only_the_new_page() {
        let total = 5;
        let new_page = 4;
        let (styled, state) = flatten(Pagination::create(1, total));
        let (_, changes) = run_click(Some(styled), page_node(new_page), state);
        let pass = restyle(&changes);
        assert_eq!(pass.len(), total + 2, "one pair of writes per button");
        for (i, (node, bg, fg)) in pass.iter().enumerate() {
            assert_eq!(*node, 2 * i + 1, "buttons must be restyled in document order");
            let (want_bg, want_fg) = if i == 0 {
                // Prev: live, because the new page is not 1.
                (NEUTRAL_BG_COLOR, NEUTRAL_TEXT)
            } else if i == total + 1 {
                // Next: live, because the new page is not the last.
                (NEUTRAL_BG_COLOR, NEUTRAL_TEXT)
            } else if i == new_page {
                (ACCENT_BG_COLOR, ACTIVE_TEXT)
            } else {
                (NEUTRAL_BG_COLOR, NEUTRAL_TEXT)
            };
            assert_eq!((*bg, *fg), (want_bg, want_fg), "button {i} after the jump");
        }
    }
    #[test]
    fn click_restyle_mutes_the_end_it_lands_on() {
        let total = 4;
        // Landing on page 1 must mute Prev…
        let (styled, state) = flatten(Pagination::create(3, total));
        let (_, changes) = run_click(Some(styled), page_node(1), state);
        let pass = restyle(&changes);
        assert_eq!(pass[0].2, DISABLED_TEXT, "Prev must go muted at page 1");
        assert_eq!(pass[total + 1].2, NEUTRAL_TEXT, "Next stays live at page 1");
        // …and landing on the last page must mute Next.
        let (styled, state) = flatten(Pagination::create(1, total));
        let (_, changes) = run_click(Some(styled), page_node(total), state);
        let pass = restyle(&changes);
        assert_eq!(pass[0].2, NEUTRAL_TEXT, "Prev is live at the last page");
        assert_eq!(pass[total + 1].2, DISABLED_TEXT, "Next must go muted at the end");
    }
    #[test]
    fn click_on_the_root_node_does_nothing() {
        // The root has no parent -> the handler must bail before touching anything.
        let (styled, state) = flatten(Pagination::create(2, 4));
        let mut probe = state.clone();
        let (update, changes) = run_click(Some(styled), 0, state);
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert_eq!(current_page_of(&mut probe), 2, "state must be untouched");
    }
    #[test]
    fn click_on_an_out_of_range_node_does_nothing() {
        let (styled, state) = flatten(Pagination::create(2, 4));
        let mut probe = state.clone();
        let (update, changes) = run_click(Some(styled), 9999, state);
        assert_eq!(
            update,
            Update::DoNothing,
            "a hit node that isn't in the tree must not panic"
        );
        assert!(changes.is_empty());
        assert_eq!(current_page_of(&mut probe), 2);
    }
    #[test]
    fn click_with_no_layout_result_does_nothing() {
        let dom = Pagination::create(2, 4).dom();
        let state = button_state(&dom, 0);
        let (update, changes) = run_click(None, PREV_NODE, state);
        assert_eq!(
            update,
            Update::DoNothing,
            "an empty LayoutWindow must be handled, not unwrapped"
        );
        assert!(changes.is_empty());
    }
    #[test]
    fn click_with_a_foreign_payload_does_nothing() {
        let (styled, _) = flatten(Pagination::create(2, 4));
        let (update, changes) = run_click(Some(styled), page_node(3), RefAny::new(0u32));
        assert_eq!(update, Update::DoNothing, "a failed downcast must bail cleanly");
        assert!(
            changes.is_empty(),
            "no state change => no restyle, even for a foreign payload"
        );
    }
    #[test]
    fn click_with_the_state_already_borrowed_does_nothing() {
        let (styled, state) = flatten(Pagination::create(2, 4));
        // A live mutable borrow on a sibling clone: the handler's own `downcast_ref`
        // must fail (returning DoNothing) instead of aliasing `&mut`.
        let mut held = state.clone();
        let guard = held
            .downcast_mut::<PaginationStateWrapper>()
            .expect("first borrow succeeds");
        let (update, changes) = run_click(Some(styled), page_node(3), state);
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        drop(guard);
    }
    #[test]
    fn click_derives_the_page_count_from_the_live_dom_not_from_the_state() {
        // The doc promises the handler "stays correct regardless of total_pages
        // drift": it reads the child count, so a stale `total_pages` in the state
        // must not change where Next stops.
        let total = 3;
        let dom = Pagination::create(1, total).dom();
        let mut state = button_state(&dom, 0);
        // Corrupt the *shared* state after the DOM was built: the rendered bar
        // still has 3 page buttons.
        {
            let mut w = state
                .downcast_mut::<PaginationStateWrapper>()
                .expect("the shared payload is a PaginationStateWrapper");
            w.inner.total_pages = 999;
        }
        let styled = StyledDom::create_from_dom(dom);
        let mut probe = state.clone();
        for _ in 0..(total + 3) {
            let (_, _) = run_click(Some(styled.clone()), next_node(total), state.clone());
        }
        assert_eq!(
            current_page_of(&mut probe),
            total,
            "Next must stop at the last *rendered* page, not at the stale total"
        );
        assert_eq!(
            state
                .downcast_ref::<PaginationStateWrapper>()
                .expect("still a PaginationStateWrapper")
                .inner
                .total_pages,
            999,
            "the handler must not rewrite total_pages behind the caller's back"
        );
    }
    #[test]
    fn click_prev_from_an_out_of_range_page_steps_down_by_one() {
        // Only reachable by writing the pub `current_page` field. The handler has no
        // clamp of its own, so it walks down one step at a time rather than snapping
        // back into range — assert that documented-by-construction behaviour rather
        // than a silent repair.
        let total = 4;
        let mut p = Pagination::create(1, total);
        p.pagination_state.inner.current_page = 99;
        let (styled, state) = flatten(p);
        let mut probe = state.clone();
        let (update, changes) = run_click(Some(styled.clone()), PREV_NODE, state.clone());
        assert_eq!(update, Update::DoNothing, "no callback installed");
        assert_eq!(current_page_of(&mut probe), 98, "Prev steps 99 -> 98");
        // Nothing is in range, so the restyle marks no page active.
        let pass = restyle(&changes);
        assert_eq!(pass.len(), total + 2);
        assert!(
            pass.iter().all(|(_, bg, _)| *bg == NEUTRAL_BG_COLOR),
            "an out-of-range page cannot be painted active"
        );
        // Next, by contrast, is a no-op: 98 is already past the last page.
        let (update, changes) = run_click(Some(styled), next_node(total), state);
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert_eq!(current_page_of(&mut probe), 98);
    }
    #[test]
    fn click_a_page_button_snaps_an_out_of_range_page_back_into_range() {
        let total = 4;
        let mut p = Pagination::create(1, total);
        p.pagination_state.inner.current_page = usize::MAX;
        let (styled, state) = flatten(p);
        let mut probe = state.clone();
        let (_, _) = run_click(Some(styled), page_node(2), state);
        assert_eq!(
            current_page_of(&mut probe),
            2,
            "an explicit page click always lands in range"
        );
    }
}