Skip to main content

azul_layout/widgets/
pagination.rs

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
23use std::vec::Vec;
24
25use azul_core::{
26    callbacks::{CoreCallbackData, Update},
27    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
28    refany::RefAny,
29};
30use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
31use 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
41use crate::callbacks::{Callback, CallbackInfo};
42
43static PAGINATION_CLASS: &[IdOrClass] =
44    &[Class(AzString::from_const_str("__azul-native-pagination"))];
45static PAGINATION_PAGE_CLASS: &[IdOrClass] =
46    &[Class(AzString::from_const_str("__azul-native-pagination-page"))];
47static PAGINATION_NAV_CLASS: &[IdOrClass] =
48    &[Class(AzString::from_const_str("__azul-native-pagination-nav"))];
49
50const PREV_LABEL: AzString = AzString::from_const_str("Prev");
51const NEXT_LABEL: AzString = AzString::from_const_str("Next");
52
53/// Callback function type invoked when the current page changes.
54pub type PaginationOnChangeCallbackType =
55    extern "C" fn(RefAny, CallbackInfo, PaginationState) -> Update;
56impl_widget_callback!(
57    PaginationOnChange,
58    OptionPaginationOnChange,
59    PaginationOnChangeCallback,
60    PaginationOnChangeCallbackType
61);
62
63azul_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)]
79pub 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)]
87pub 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)]
97pub 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).
106const PAGE_BORDER_COLOR: ColorU = ColorU { r: 206, g: 212, b: 218, a: 255 };
107/// Active-page background (#0d6efd, accent blue).
108const ACCENT_BG_COLOR: ColorU = ColorU { r: 13, g: 110, b: 253, a: 255 };
109/// Neutral (inactive) background (white).
110const NEUTRAL_BG_COLOR: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
111/// Active-page text colour (white).
112const ACTIVE_TEXT: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
113/// Neutral text colour (#212529, dark).
114const NEUTRAL_TEXT: ColorU = ColorU { r: 33, g: 37, b: 41, a: 255 };
115/// Disabled (Prev/Next at a bound) text colour (#adb5bd, muted grey).
116const DISABLED_TEXT: ColorU = ColorU { r: 173, g: 181, b: 189, a: 255 };
117
118const ACCENT_BG_ITEMS: &[StyleBackgroundContent] =
119    &[StyleBackgroundContent::Color(ACCENT_BG_COLOR)];
120const ACCENT_BG: StyleBackgroundContentVec =
121    StyleBackgroundContentVec::from_const_slice(ACCENT_BG_ITEMS);
122const NEUTRAL_BG_ITEMS: &[StyleBackgroundContent] =
123    &[StyleBackgroundContent::Color(NEUTRAL_BG_COLOR)];
124const NEUTRAL_BG: StyleBackgroundContentVec =
125    StyleBackgroundContentVec::from_const_slice(NEUTRAL_BG_ITEMS);
126
127const PAGE_RADIUS: isize = 6;
128
129/// Row container: a horizontal flex row that hugs its content.
130static 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
144fn build_button_style(
145    active: bool,
146    disabled: bool,
147    is_first: bool,
148    is_last: bool,
149) -> CssPropertyWithConditionsVec {
150    let bg = if active { ACCENT_BG } else { NEUTRAL_BG };
151    let text = if active {
152        ACTIVE_TEXT
153    } else if disabled {
154        DISABLED_TEXT
155    } else {
156        NEUTRAL_TEXT
157    };
158
159    let mut v: Vec<CssPropertyWithConditions> = vec![
160        CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
161        CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
162            LayoutFlexDirection::Row,
163        )),
164        CssPropertyWithConditions::simple(CssProperty::const_justify_content(
165            LayoutJustifyContent::Center,
166        )),
167        CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
168        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
169            0,
170        ))),
171        // Keep single-digit page buttons from collapsing too narrow.
172        CssPropertyWithConditions::simple(CssProperty::const_min_width(LayoutMinWidth::const_px(
173            36,
174        ))),
175        // padding: 6px 12px
176        CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
177            6,
178        ))),
179        CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
180            LayoutPaddingBottom::const_px(6),
181        )),
182        CssPropertyWithConditions::simple(CssProperty::const_padding_left(
183            LayoutPaddingLeft::const_px(12),
184        )),
185        CssPropertyWithConditions::simple(CssProperty::const_padding_right(
186            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        CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
191            LayoutBorderTopWidth::const_px(1),
192        )),
193        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
194            LayoutBorderBottomWidth::const_px(1),
195        )),
196        CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
197            LayoutBorderRightWidth::const_px(1),
198        )),
199        CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
200            inner: BorderStyle::Solid,
201        })),
202        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
203            StyleBorderBottomStyle {
204                inner: BorderStyle::Solid,
205            },
206        )),
207        CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
208            StyleBorderRightStyle {
209                inner: BorderStyle::Solid,
210            },
211        )),
212        CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
213            inner: PAGE_BORDER_COLOR,
214        })),
215        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
216            StyleBorderBottomColor {
217                inner: PAGE_BORDER_COLOR,
218            },
219        )),
220        CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
221            StyleBorderRightColor {
222                inner: PAGE_BORDER_COLOR,
223            },
224        )),
225        CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
226        CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
227        CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
228        CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
229        CssPropertyWithConditions::simple(CssProperty::const_background_content(bg)),
230        CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
231            inner: text,
232        })),
233    ];
234
235    if is_first {
236        v.push(CssPropertyWithConditions::simple(
237            CssProperty::const_border_left_width(LayoutBorderLeftWidth::const_px(1)),
238        ));
239        v.push(CssPropertyWithConditions::simple(
240            CssProperty::const_border_left_style(StyleBorderLeftStyle {
241                inner: BorderStyle::Solid,
242            }),
243        ));
244        v.push(CssPropertyWithConditions::simple(
245            CssProperty::const_border_left_color(StyleBorderLeftColor {
246                inner: PAGE_BORDER_COLOR,
247            }),
248        ));
249        v.push(CssPropertyWithConditions::simple(
250            CssProperty::const_border_top_left_radius(StyleBorderTopLeftRadius::const_px(
251                PAGE_RADIUS,
252            )),
253        ));
254        v.push(CssPropertyWithConditions::simple(
255            CssProperty::const_border_bottom_left_radius(StyleBorderBottomLeftRadius::const_px(
256                PAGE_RADIUS,
257            )),
258        ));
259    }
260    if is_last {
261        v.push(CssPropertyWithConditions::simple(
262            CssProperty::const_border_top_right_radius(StyleBorderTopRightRadius::const_px(
263                PAGE_RADIUS,
264            )),
265        ));
266        v.push(CssPropertyWithConditions::simple(
267            CssProperty::const_border_bottom_right_radius(StyleBorderBottomRightRadius::const_px(
268                PAGE_RADIUS,
269            )),
270        ));
271    }
272
273    CssPropertyWithConditionsVec::from_vec(v)
274}
275
276impl 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    #[must_use] pub fn create(current_page: usize, total_pages: usize) -> Self {
280        let total_pages = total_pages.max(1);
281        let current_page = current_page.clamp(1, total_pages);
282        Self {
283            pagination_state: PaginationStateWrapper {
284                inner: PaginationState {
285                    current_page,
286                    total_pages,
287                },
288                ..Default::default()
289            },
290            container_style: CssPropertyWithConditionsVec::from_const_slice(
291                PAGINATION_CONTAINER_STYLE,
292            ),
293        }
294    }
295
296    /// Sets the current (1-based) page, clamped into `[1, total_pages]`.
297    #[inline]
298    pub fn set_current_page(&mut self, current_page: usize) {
299        let total = self.pagination_state.inner.total_pages.max(1);
300        self.pagination_state.inner.current_page = current_page.clamp(1, total);
301    }
302
303    /// Builder-style setter for the current page.
304    #[inline]
305    #[must_use] pub fn with_current_page(mut self, current_page: usize) -> Self {
306        self.set_current_page(current_page);
307        self
308    }
309
310    #[inline]
311    #[must_use] pub fn swap_with_default(&mut self) -> Self {
312        let mut s = Self::create(1, 1);
313        core::mem::swap(&mut s, self);
314        s
315    }
316
317    #[inline]
318    pub fn set_on_change<C: Into<PaginationOnChangeCallback>>(
319        &mut self,
320        data: RefAny,
321        on_change: C,
322    ) {
323        self.pagination_state.on_change = Some(PaginationOnChange {
324            callback: on_change.into(),
325            refany: data,
326        })
327        .into();
328    }
329
330    #[inline]
331    #[must_use] pub fn with_on_change<C: Into<PaginationOnChangeCallback>>(
332        mut self,
333        data: RefAny,
334        on_change: C,
335    ) -> Self {
336        self.set_on_change(data, on_change);
337        self
338    }
339
340    #[must_use] pub fn dom(self) -> Dom {
341        use azul_core::{
342            callbacks::CoreCallback,
343            dom::{EventFilter, HoverEventFilter},
344            refany::OptionRefAny,
345        };
346
347        let current = self.pagination_state.inner.current_page;
348        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        let state = RefAny::new(self.pagination_state);
353
354        let make_button =
355            |label: AzString, class: &'static [IdOrClass], style: CssPropertyWithConditionsVec| {
356                Dom::create_text(label)
357                    .with_ids_and_classes(IdOrClassVec::from_const_slice(class))
358                    .with_css_props(style)
359                    .with_callbacks(
360                        vec![CoreCallbackData {
361                            event: EventFilter::Hover(HoverEventFilter::MouseUp),
362                            callback: CoreCallback {
363                                cb: on_page_click as usize,
364                                ctx: OptionRefAny::None,
365                            },
366                            refany: state.clone(),
367                        }]
368                        .into(),
369                    )
370                    .with_tab_index(TabIndex::Auto)
371            };
372
373        let mut children: Vec<Dom> = Vec::with_capacity(total.saturating_add(2));
374
375        // Prev (first, left-rounded; disabled-look at page 1).
376        children.push(make_button(
377            PREV_LABEL,
378            PAGINATION_NAV_CLASS,
379            build_button_style(false, current <= 1, true, false),
380        ));
381
382        // Page-number buttons 1..=total.
383        for page in 1..=total {
384            children.push(make_button(
385                AzString::from(format!("{page}").as_str()),
386                PAGINATION_PAGE_CLASS,
387                build_button_style(page == current, false, false, false),
388            ));
389        }
390
391        // Next (last, right-rounded; disabled-look at the final page).
392        children.push(make_button(
393            NEXT_LABEL,
394            PAGINATION_NAV_CLASS,
395            build_button_style(false, current >= total, false, true),
396        ));
397
398        Dom::create_div()
399            .with_ids_and_classes(IdOrClassVec::from_const_slice(PAGINATION_CLASS))
400            .with_css_props(self.container_style)
401            .with_children(children.into())
402    }
403}
404
405impl Default for Pagination {
406    fn default() -> Self {
407        Self::create(1, 1)
408    }
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.
415extern "C" fn on_page_click(mut data: RefAny, mut info: CallbackInfo) -> Update {
416    use azul_core::dom::DomNodeId;
417
418    let clicked = info.get_hit_node();
419    let Some(parent) = info.get_parent(clicked) else {
420        return Update::DoNothing;
421    };
422
423    // Collect the buttons in document order: [Prev, page1 … pageN, Next].
424    let mut buttons: Vec<DomNodeId> = Vec::new();
425    let mut cur = info.get_first_child(parent);
426    while let Some(node) = cur {
427        buttons.push(node);
428        cur = info.get_next_sibling(node);
429    }
430    let n = buttons.len();
431    if n < 2 {
432        return Update::DoNothing;
433    }
434    // Page buttons occupy positions 1..=total; Prev=0, Next=n-1.
435    let total = n - 2;
436
437    let Some(pos) = buttons.iter().position(|b| *b == clicked) else {
438        return Update::DoNothing;
439    };
440
441    let current = {
442        let Some(pg) = data.downcast_ref::<PaginationStateWrapper>() else {
443            return Update::DoNothing;
444        };
445        pg.inner.current_page
446    };
447
448    let new_page = if pos == 0 {
449        // Prev
450        if current > 1 {
451            current - 1
452        } else {
453            current
454        }
455    } else if pos == n - 1 {
456        // Next
457        if current < total {
458            current + 1
459        } else {
460            current
461        }
462    } else {
463        // A page-number button: its 1-based page equals its sibling position.
464        pos
465    };
466
467    if new_page == current {
468        // Clicked the current page, or a disabled Prev/Next at a bound.
469        return Update::DoNothing;
470    }
471
472    let result = {
473        let Some(mut pg) = data.downcast_mut::<PaginationStateWrapper>() else {
474            return Update::DoNothing;
475        };
476        pg.inner.current_page = new_page;
477        let inner = pg.inner;
478        let pg = &mut *pg;
479        match pg.on_change.as_mut() {
480            Some(PaginationOnChange { callback, refany }) => {
481                (callback.cb)(refany.clone(), info, inner)
482            }
483            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    for (i, node) in buttons.iter().enumerate() {
490        let (bg, text) = if i == 0 {
491            // Prev
492            let disabled = new_page <= 1;
493            (NEUTRAL_BG, if disabled { DISABLED_TEXT } else { NEUTRAL_TEXT })
494        } else if i == n - 1 {
495            // Next
496            let disabled = new_page >= total;
497            (NEUTRAL_BG, if disabled { DISABLED_TEXT } else { NEUTRAL_TEXT })
498        } else if i == new_page {
499            (ACCENT_BG, ACTIVE_TEXT)
500        } else {
501            (NEUTRAL_BG, NEUTRAL_TEXT)
502        };
503        info.set_css_property(*node, CssProperty::const_background_content(bg));
504        info.set_css_property(*node, CssProperty::const_text_color(StyleTextColor { inner: text }));
505    }
506
507    result
508}
509
510impl From<Pagination> for Dom {
511    fn from(p: Pagination) -> Self {
512        p.dom()
513    }
514}
515
516#[cfg(test)]
517#[allow(clippy::float_cmp, clippy::too_many_lines)]
518mod 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            _ => None,
576        }
577    }
578
579    fn classes(node: &Dom) -> Vec<String> {
580        node.root
581            .get_ids_and_classes()
582            .as_ref()
583            .iter()
584            .filter_map(|c| match c {
585                IdOrClass::Class(s) => Some(s.as_str().to_string()),
586                IdOrClass::Id(_) => None,
587            })
588            .collect()
589    }
590
591    /// The one text colour a button declares (asserting there is at most one — a
592    /// second declaration would silently shadow the first at cascade time).
593    fn text_color(props: &[CssProperty]) -> Option<ColorU> {
594        let found: Vec<ColorU> = props
595            .iter()
596            .filter_map(|p| match p {
597                CssProperty::TextColor(v) => v.get_property().map(|c| c.inner),
598                _ => None,
599            })
600            .collect();
601        assert!(found.len() <= 1, "a button must declare at most one text colour");
602        found.first().copied()
603    }
604
605    /// The one flat background colour a button declares.
606    fn background_color(props: &[CssProperty]) -> Option<ColorU> {
607        let found: Vec<&StyleBackgroundContentVec> = props
608            .iter()
609            .filter_map(|p| match p {
610                CssProperty::BackgroundContent(v) => v.get_property(),
611                _ => None,
612            })
613            .collect();
614        assert!(found.len() <= 1, "a button must declare at most one background");
615        let bg: &StyleBackgroundContentVec = *found.first()?;
616        assert_eq!(bg.as_ref().len(), 1, "a button must declare exactly one background layer");
617        match &bg.as_ref()[0] {
618            StyleBackgroundContent::Color(c) => Some(*c),
619            other => panic!("pagination background is not a flat colour: {other:?}"),
620        }
621    }
622
623    /// The `f32` of a `PixelValue`, asserting the length is an absolute `px`. An
624    /// `em`/`%` slipping into the button chrome would resolve against the parent
625    /// font/box instead of the intended fixed padding, border or radius.
626    fn px(pv: &PixelValue) -> f32 {
627        assert_eq!(
628            pv.metric,
629            SizeMetric::Px,
630            "pagination geometry must be absolute px, got {:?}",
631            pv.metric
632        );
633        pv.number.get()
634    }
635
636    /// Every length a style declares (min-width, paddings, border widths, font
637    /// size, corner radii).
638    fn pixel_values(props: &[CssProperty]) -> Vec<PixelValue> {
639        props
640            .iter()
641            .filter_map(|p| match p {
642                CssProperty::MinWidth(v) => v.get_property().map(|x| x.inner),
643                CssProperty::PaddingTop(v) => v.get_property().map(|x| x.inner),
644                CssProperty::PaddingBottom(v) => v.get_property().map(|x| x.inner),
645                CssProperty::PaddingLeft(v) => v.get_property().map(|x| x.inner),
646                CssProperty::PaddingRight(v) => v.get_property().map(|x| x.inner),
647                CssProperty::BorderTopWidth(v) => v.get_property().map(|x| x.inner),
648                CssProperty::BorderBottomWidth(v) => v.get_property().map(|x| x.inner),
649                CssProperty::BorderLeftWidth(v) => v.get_property().map(|x| x.inner),
650                CssProperty::BorderRightWidth(v) => v.get_property().map(|x| x.inner),
651                CssProperty::FontSize(v) => v.get_property().map(|x| x.inner),
652                CssProperty::BorderTopLeftRadius(v) => v.get_property().map(|x| x.inner),
653                CssProperty::BorderBottomLeftRadius(v) => v.get_property().map(|x| x.inner),
654                CssProperty::BorderTopRightRadius(v) => v.get_property().map(|x| x.inner),
655                CssProperty::BorderBottomRightRadius(v) => v.get_property().map(|x| x.inner),
656                _ => None,
657            })
658            .collect()
659    }
660
661    /// The four corner radii a style declares, as `(top-left, bottom-left,
662    /// top-right, bottom-right)`; `None` where the corner is left square.
663    fn radii(props: &[CssProperty]) -> (Option<f32>, Option<f32>, Option<f32>, Option<f32>) {
664        let find = |f: &dyn Fn(&CssProperty) -> Option<f32>| props.iter().find_map(f);
665        (
666            find(&|p| match p {
667                CssProperty::BorderTopLeftRadius(v) => v.get_property().map(|r| px(&r.inner)),
668                _ => None,
669            }),
670            find(&|p| match p {
671                CssProperty::BorderBottomLeftRadius(v) => v.get_property().map(|r| px(&r.inner)),
672                _ => None,
673            }),
674            find(&|p| match p {
675                CssProperty::BorderTopRightRadius(v) => v.get_property().map(|r| px(&r.inner)),
676                _ => None,
677            }),
678            find(&|p| match p {
679                CssProperty::BorderBottomRightRadius(v) => v.get_property().map(|r| px(&r.inner)),
680                _ => None,
681            }),
682        )
683    }
684
685    /// Whether the style declares a *left* border on all three of width/style/colour
686    /// — the "I am the first button in the joined bar" marker.
687    fn has_left_border(props: &[CssProperty]) -> (bool, bool, bool) {
688        (
689            props.iter().any(|p| matches!(p, CssProperty::BorderLeftWidth(_))),
690            props.iter().any(|p| matches!(p, CssProperty::BorderLeftStyle(_))),
691            props.iter().any(|p| matches!(p, CssProperty::BorderLeftColor(_))),
692        )
693    }
694
695    /// Every `(active, disabled, is_first, is_last)` the builder can be handed.
696    fn all_flag_combinations() -> Vec<(bool, bool, bool, bool)> {
697        let mut out = Vec::with_capacity(16);
698        for a in [false, true] {
699            for d in [false, true] {
700                for f in [false, true] {
701                    for l in [false, true] {
702                        out.push((a, d, f, l));
703                    }
704                }
705            }
706        }
707        out
708    }
709
710    /// A `RefAny` payload recording every state a user `on_change` observes.
711    struct ChangeLog {
712        seen: Vec<PaginationState>,
713    }
714
715    extern "C" fn record_change(
716        mut data: RefAny,
717        _: CallbackInfo,
718        state: PaginationState,
719    ) -> Update {
720        if let Some(mut log) = data.downcast_mut::<ChangeLog>() {
721            log.seen.push(state);
722        }
723        Update::RefreshDom
724    }
725
726    extern "C" fn change_do_nothing(_: RefAny, _: CallbackInfo, _: PaginationState) -> Update {
727        Update::DoNothing
728    }
729
730    extern "C" fn change_refresh_all(_: RefAny, _: CallbackInfo, _: PaginationState) -> Update {
731        Update::RefreshDomAllWindows
732    }
733
734    /// Forces the `fn`-item -> `fn`-pointer coercion the `Into` bound needs.
735    fn cb(f: PaginationOnChangeCallbackType) -> PaginationOnChangeCallback {
736        f.into()
737    }
738
739    fn logged(data: &mut RefAny) -> Vec<PaginationState> {
740        data.downcast_ref::<ChangeLog>()
741            .expect("payload must still be a ChangeLog")
742            .seen
743            .clone()
744    }
745
746    fn current_page_of(data: &mut RefAny) -> usize {
747        data.downcast_ref::<PaginationStateWrapper>()
748            .expect("payload must still be a PaginationStateWrapper")
749            .inner
750            .current_page
751    }
752
753    /// The shared state `RefAny` carried by button `i`'s click callback.
754    fn button_state(dom: &Dom, i: usize) -> RefAny {
755        dom.children.as_ref()[i]
756            .root
757            .get_callbacks()
758            .as_ref()
759            .first()
760            .expect("every pagination button carries a click callback")
761            .refany
762            .clone()
763    }
764
765    /// Flattened node id of the `Prev` button (root is 0, children follow in order).
766    const PREV_NODE: usize = 1;
767    /// Flattened node id of page `p` (1-based).
768    const fn page_node(p: usize) -> usize {
769        p + 1
770    }
771    /// Flattened node id of the `Next` button for a `total`-page pager.
772    const fn next_node(total: usize) -> usize {
773        total + 2
774    }
775
776    /// A `DomLayoutResult` with an *empty* layout tree: `on_page_click` only walks
777    /// `styled_dom.node_hierarchy`, so no real layout (and no font) is needed.
778    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
779        DomLayoutResult {
780            styled_dom,
781            layout_tree: LayoutTree {
782                nodes: Vec::new(),
783                warm: Vec::new(),
784                cold: Vec::new(),
785                root: 0,
786                dom_to_layout: BTreeMap::new(),
787                children_arena: Vec::new(),
788                children_offsets: Vec::new(),
789                subtree_needs_intrinsic: Vec::new(),
790            },
791            calculated_positions: Vec::new(),
792            viewport: LogicalRect::zero(),
793            display_list: DisplayList::default(),
794            scroll_ids: HashMap::new(),
795            scroll_id_to_node_id: HashMap::new(),
796        }
797    }
798
799    /// Flattens `p.dom()` and hands back the shared state `RefAny` its buttons carry.
800    fn flatten(p: Pagination) -> (StyledDom, RefAny) {
801        let dom = p.dom();
802        let state = button_state(&dom, 0);
803        (StyledDom::create_from_dom(dom), state)
804    }
805
806    /// Invokes `on_page_click` against a `LayoutWindow` holding `styled` (or nothing
807    /// at all, when `styled` is `None`), with flattened node `hit` as the hit node.
808    /// Returns the `Update` plus every recorded `CallbackChange`.
809    fn run_click(
810        styled: Option<StyledDom>,
811        hit: usize,
812        data: RefAny,
813    ) -> (Update, Vec<CallbackChange>) {
814        let mut layout_window =
815            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
816        if let Some(sd) = styled {
817            layout_window
818                .layout_results
819                .insert(DomId::ROOT_ID, layout_result(sd));
820        }
821
822        let renderer_resources = RendererResources::default();
823        let previous_window_state: Option<FullWindowState> = None;
824        let current_window_state = FullWindowState::default();
825        let gl_context = OptionGlContextPtr::None;
826        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
827            BTreeMap::new();
828        let window_handle = RawWindowHandle::Unsupported;
829        let system_callbacks = ExternalSystemCallbacks::rust_internal();
830
831        let ref_data = CallbackInfoRefData {
832            layout_window: &layout_window,
833            renderer_resources: &renderer_resources,
834            previous_window_state: &previous_window_state,
835            current_window_state: &current_window_state,
836            gl_context: &gl_context,
837            current_scroll_manager: &scroll_states,
838            current_window_handle: &window_handle,
839            system_callbacks: &system_callbacks,
840            system_style: Arc::new(SystemStyle::default()),
841            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
842            #[cfg(feature = "icu")]
843            icu_localizer: IcuLocalizerHandle::default(),
844            ctx: OptionRefAny::None,
845        };
846
847        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
848
849        let info = CallbackInfo::new(
850            &ref_data,
851            &changes,
852            DomNodeId {
853                dom: DomId::ROOT_ID,
854                node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(hit))),
855            },
856            OptionLogicalPosition::None,
857            OptionLogicalPosition::None,
858        );
859
860        let update = on_page_click(data, info);
861        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
862        (update, recorded)
863    }
864
865    /// Decodes a live-restyle transaction into `(node index, background, text)`
866    /// triples — the handler pushes exactly one background and one colour write per
867    /// button, in that order.
868    fn restyle(changes: &[CallbackChange]) -> Vec<(usize, ColorU, ColorU)> {
869        assert_eq!(
870            changes.len() % 2,
871            0,
872            "a restyle pass writes background+colour in pairs"
873        );
874        let decode = |c: &CallbackChange| -> (usize, CssProperty) {
875            match c {
876                CallbackChange::ChangeNodeCssProperties {
877                    dom_id,
878                    node_id,
879                    properties,
880                } => {
881                    assert_eq!(*dom_id, DomId::ROOT_ID, "restyle must stay in the root DOM");
882                    assert_eq!(
883                        properties.as_ref().len(),
884                        1,
885                        "set_css_property writes exactly one property per change"
886                    );
887                    (node_id.index(), properties.as_ref()[0].clone())
888                }
889                other => panic!("unexpected change pushed by on_page_click: {other:?}"),
890            }
891        };
892        changes
893            .chunks(2)
894            .map(|pair| {
895                let (bg_node, bg_prop) = decode(&pair[0]);
896                let (fg_node, fg_prop) = decode(&pair[1]);
897                assert_eq!(bg_node, fg_node, "both writes must target the same button");
898                let bg = background_color(&[bg_prop])
899                    .expect("the first write of each pair is the background");
900                let fg =
901                    text_color(&[fg_prop]).expect("the second write of each pair is the colour");
902                (bg_node, bg, fg)
903            })
904            .collect()
905    }
906
907    // ==================================================================
908    // build_button_style
909    // ==================================================================
910
911    #[test]
912    fn build_button_style_never_panics_and_is_deterministic() {
913        for (a, d, f, l) in all_flag_combinations() {
914            let style = build_button_style(a, d, f, l);
915            assert!(
916                !style.as_ref().is_empty(),
917                "({a},{d},{f},{l}) produced an empty style"
918            );
919            assert_eq!(
920                declared(&style),
921                declared(&build_button_style(a, d, f, l)),
922                "({a},{d},{f},{l}) is not a pure function of its flags"
923            );
924        }
925    }
926
927    #[test]
928    fn build_button_style_declares_every_property_at_most_once() {
929        // A duplicate declaration would silently shadow the earlier one at cascade
930        // time, making the widget's look depend on declaration order.
931        for (a, d, f, l) in all_flag_combinations() {
932            let props = declared(&build_button_style(a, d, f, l));
933            let mut types: Vec<CssPropertyType> = props.iter().map(CssProperty::get_type).collect();
934            let len = types.len();
935            types.sort_unstable();
936            types.dedup();
937            assert_eq!(types.len(), len, "duplicate declaration for ({a},{d},{f},{l})");
938        }
939    }
940
941    #[test]
942    fn build_button_style_is_unconditional() {
943        // Every declaration is `simple` — a stray `apply_if` would make the button
944        // silently lose its fill under some pseudo-state.
945        for (a, d, f, l) in all_flag_combinations() {
946            for p in build_button_style(a, d, f, l).as_ref() {
947                assert!(
948                    p.apply_if.as_ref().is_empty(),
949                    "({a},{d},{f},{l}) declared a conditional property: {:?}",
950                    p.property
951                );
952            }
953        }
954    }
955
956    #[test]
957    fn build_button_style_active_wins_over_disabled_for_the_text_colour() {
958        // `active` is checked first, so an active-*and*-disabled button reads as
959        // active; only a non-active disabled button gets the muted grey.
960        for (flags, expected) in [
961            ((true, true), ACTIVE_TEXT),
962            ((true, false), ACTIVE_TEXT),
963            ((false, true), DISABLED_TEXT),
964            ((false, false), NEUTRAL_TEXT),
965        ] {
966            let (a, d) = flags;
967            let props = declared(&build_button_style(a, d, false, false));
968            assert_eq!(
969                text_color(&props),
970                Some(expected),
971                "active={a} disabled={d} picked the wrong text colour"
972            );
973        }
974    }
975
976    #[test]
977    fn build_button_style_paints_the_accent_fill_only_when_active() {
978        for (a, d, f, l) in all_flag_combinations() {
979            let props = declared(&build_button_style(a, d, f, l));
980            let expected = if a { ACCENT_BG_COLOR } else { NEUTRAL_BG_COLOR };
981            assert_eq!(
982                background_color(&props),
983                Some(expected),
984                "({a},{d},{f},{l}) has the wrong fill — `disabled` must not tint the background"
985            );
986        }
987    }
988
989    #[test]
990    fn build_button_style_rounds_only_the_outer_corners() {
991        for (a, d, f, l) in all_flag_combinations() {
992            let props = declared(&build_button_style(a, d, f, l));
993            let (tl, bl, tr, br) = radii(&props);
994            let r = PAGE_RADIUS as f32;
995
996            assert_eq!(tl, if f { Some(r) } else { None }, "top-left for is_first={f}");
997            assert_eq!(bl, if f { Some(r) } else { None }, "bottom-left for is_first={f}");
998            assert_eq!(tr, if l { Some(r) } else { None }, "top-right for is_last={l}");
999            assert_eq!(br, if l { Some(r) } else { None }, "bottom-right for is_last={l}");
1000        }
1001    }
1002
1003    #[test]
1004    fn build_button_style_gives_only_the_first_button_a_left_border() {
1005        // Adjacent buttons must share a single 1px separator: everyone draws a right
1006        // border, only the leftmost also draws a left one.
1007        for (a, d, f, l) in all_flag_combinations() {
1008            let props = declared(&build_button_style(a, d, f, l));
1009            assert_eq!(
1010                has_left_border(&props),
1011                (f, f, f),
1012                "left border must be present iff is_first ({a},{d},{f},{l})"
1013            );
1014            assert!(
1015                props.iter().any(|p| matches!(p, CssProperty::BorderRightWidth(_))),
1016                "every button draws its own right border"
1017            );
1018        }
1019    }
1020
1021    #[test]
1022    fn build_button_style_property_count_is_purely_position_dependent() {
1023        // The colour flags must not add or drop declarations — only the position
1024        // flags do (left border + 2 radii for first, 2 radii for last).
1025        let base = build_button_style(false, false, false, false).as_ref().len();
1026        for (a, d) in [(false, false), (true, false), (false, true), (true, true)] {
1027            assert_eq!(
1028                build_button_style(a, d, false, false).as_ref().len(),
1029                base,
1030                "colour flags changed the declaration count"
1031            );
1032            assert_eq!(
1033                build_button_style(a, d, true, false).as_ref().len(),
1034                base + 5,
1035                "is_first must add exactly left width/style/colour + 2 radii"
1036            );
1037            assert_eq!(
1038                build_button_style(a, d, false, true).as_ref().len(),
1039                base + 2,
1040                "is_last must add exactly 2 radii"
1041            );
1042            assert_eq!(
1043                build_button_style(a, d, true, true).as_ref().len(),
1044                base + 7,
1045                "a single-button bar is first *and* last"
1046            );
1047        }
1048    }
1049
1050    #[test]
1051    fn build_button_style_uses_only_absolute_px_lengths() {
1052        for (a, d, f, l) in all_flag_combinations() {
1053            let props = declared(&build_button_style(a, d, f, l));
1054            let lengths = pixel_values(&props);
1055            assert!(!lengths.is_empty(), "a button must declare some geometry");
1056            for pv in &lengths {
1057                let v = px(pv); // asserts SizeMetric::Px
1058                assert!(v.is_finite(), "non-finite length {v} in ({a},{d},{f},{l})");
1059                assert!(v >= 0.0, "negative length {v} in ({a},{d},{f},{l})");
1060            }
1061        }
1062    }
1063
1064    #[test]
1065    fn button_palette_is_opaque_and_the_states_are_visually_distinct() {
1066        for (name, c) in [
1067            ("page border", PAGE_BORDER_COLOR),
1068            ("accent bg", ACCENT_BG_COLOR),
1069            ("neutral bg", NEUTRAL_BG_COLOR),
1070            ("active text", ACTIVE_TEXT),
1071            ("neutral text", NEUTRAL_TEXT),
1072            ("disabled text", DISABLED_TEXT),
1073        ] {
1074            assert_eq!(c.a, 255, "{name} must be fully opaque");
1075        }
1076        assert_ne!(ACCENT_BG_COLOR, NEUTRAL_BG_COLOR, "the active page must stand out");
1077        assert_ne!(ACTIVE_TEXT, NEUTRAL_TEXT, "active text must read on the accent fill");
1078        assert_ne!(NEUTRAL_TEXT, DISABLED_TEXT, "a disabled end must look disabled");
1079        // The active-page text sits on the accent fill and must not equal it.
1080        assert_ne!(ACTIVE_TEXT, ACCENT_BG_COLOR, "active text would be invisible");
1081        assert_ne!(NEUTRAL_TEXT, NEUTRAL_BG_COLOR, "neutral text would be invisible");
1082        assert_ne!(DISABLED_TEXT, NEUTRAL_BG_COLOR, "disabled text would be invisible");
1083    }
1084
1085    // ==================================================================
1086    // Pagination::create
1087    // ==================================================================
1088
1089    #[test]
1090    fn create_clamps_current_page_into_range() {
1091        for (cur, total, want_cur, want_total) in [
1092            // (input page, input total, expected page, expected total)
1093            (0usize, 0usize, 1usize, 1usize),
1094            (0, 1, 1, 1),
1095            (1, 0, 1, 1),
1096            (0, 5, 1, 5),
1097            (1, 5, 1, 5),
1098            (3, 5, 3, 5),
1099            (5, 5, 5, 5),
1100            (6, 5, 5, 5),
1101            (usize::MAX, 5, 5, 5),
1102            (usize::MAX, 1, 1, 1),
1103            (usize::MAX, 0, 1, 1),
1104            (5, usize::MAX, 5, usize::MAX),
1105            (0, usize::MAX, 1, usize::MAX),
1106            (usize::MAX, usize::MAX, usize::MAX, usize::MAX),
1107        ] {
1108            let p = Pagination::create(cur, total);
1109            assert_eq!(
1110                p.pagination_state.inner,
1111                PaginationState {
1112                    current_page: want_cur,
1113                    total_pages: want_total,
1114                },
1115                "create({cur}, {total})"
1116            );
1117        }
1118    }
1119
1120    #[test]
1121    fn create_never_yields_a_zero_or_out_of_range_page() {
1122        // The 1-based invariant is what every consumer (dom(), the click handler,
1123        // the restyle pass) relies on; a 0 page would mean "no page is current".
1124        for total in [0usize, 1, 2, 3, 7, 64, 1024, usize::MAX - 1, usize::MAX] {
1125            for cur in [0usize, 1, 2, 63, 1023, usize::MAX - 1, usize::MAX] {
1126                let s = Pagination::create(cur, total).pagination_state.inner;
1127                assert!(s.total_pages >= 1, "create({cur}, {total}) left 0 pages");
1128                assert!(s.current_page >= 1, "create({cur}, {total}) produced page 0");
1129                assert!(
1130                    s.current_page <= s.total_pages,
1131                    "create({cur}, {total}) escaped the upper bound"
1132                );
1133                // Clamping must never *invent* a page: an in-range input survives.
1134                if cur >= 1 && cur <= total {
1135                    assert_eq!(s.current_page, cur, "an in-range page must pass through");
1136                }
1137            }
1138        }
1139    }
1140
1141    #[test]
1142    fn create_installs_no_callback_and_the_shared_const_container_style() {
1143        let p = Pagination::create(2, 4);
1144        assert!(
1145            p.pagination_state.on_change.as_ref().is_none(),
1146            "create must not install a callback"
1147        );
1148        assert_eq!(
1149            p.container_style.as_ref(),
1150            PAGINATION_CONTAINER_STYLE,
1151            "create must reuse the const container style"
1152        );
1153    }
1154
1155    #[test]
1156    fn default_equals_create_one_one() {
1157        assert_eq!(Pagination::default(), Pagination::create(1, 1));
1158        let s = Pagination::default().pagination_state.inner;
1159        assert_eq!(s.current_page, 1);
1160        assert_eq!(s.total_pages, 1);
1161    }
1162
1163    // ==================================================================
1164    // Pagination::set_current_page / with_current_page
1165    // ==================================================================
1166
1167    #[test]
1168    fn set_current_page_clamps_into_range() {
1169        for (input, want) in [
1170            (0usize, 1usize),
1171            (1, 1),
1172            (4, 4),
1173            (7, 7),
1174            (8, 7),
1175            (usize::MAX, 7),
1176            (usize::MAX - 1, 7),
1177        ] {
1178            let mut p = Pagination::create(1, 7);
1179            p.set_current_page(input);
1180            assert_eq!(
1181                p.pagination_state.inner.current_page, want,
1182                "set_current_page({input}) on a 7-page pager"
1183            );
1184            assert_eq!(
1185                p.pagination_state.inner.total_pages, 7,
1186                "set_current_page must not touch total_pages"
1187            );
1188        }
1189    }
1190
1191    #[test]
1192    fn set_current_page_survives_a_hand_corrupted_zero_total() {
1193        // `total_pages` is a pub field, so it can be zeroed behind the constructor's
1194        // back. `clamp(1, 0)` would panic (min > max); the `.max(1)` guard is what
1195        // prevents that.
1196        let mut p = Pagination::create(1, 3);
1197        p.pagination_state.inner.total_pages = 0;
1198        for input in [0usize, 1, 5, usize::MAX] {
1199            p.set_current_page(input);
1200            assert_eq!(
1201                p.pagination_state.inner.current_page, 1,
1202                "a 0-page pager must collapse every page to 1"
1203            );
1204        }
1205        assert_eq!(
1206            p.pagination_state.inner.total_pages, 0,
1207            "the setter must not silently repair total_pages"
1208        );
1209    }
1210
1211    #[test]
1212    fn set_current_page_is_idempotent_and_reversible() {
1213        let mut p = Pagination::create(1, 10);
1214        for page in [1usize, 10, 5, 5, 1, 10] {
1215            p.set_current_page(page);
1216            let once = p.pagination_state.inner.current_page;
1217            p.set_current_page(page);
1218            assert_eq!(p.pagination_state.inner.current_page, once, "not idempotent");
1219            assert_eq!(once, page);
1220        }
1221    }
1222
1223    #[test]
1224    fn set_current_page_at_the_usize_max_total_does_not_overflow() {
1225        let mut p = Pagination::create(1, usize::MAX);
1226        p.set_current_page(usize::MAX);
1227        assert_eq!(p.pagination_state.inner.current_page, usize::MAX);
1228        p.set_current_page(0);
1229        assert_eq!(p.pagination_state.inner.current_page, 1);
1230    }
1231
1232    #[test]
1233    fn with_current_page_matches_set_current_page_and_keeps_the_callback() {
1234        for input in [0usize, 1, 3, 9, usize::MAX] {
1235            let mut expected = Pagination::create(1, 6);
1236            expected.set_current_page(input);
1237
1238            let got = Pagination::create(1, 6).with_current_page(input);
1239            assert_eq!(got, expected, "builder and setter must agree for {input}");
1240        }
1241
1242        // The builder form must not drop an already-installed callback.
1243        let p = Pagination::create(1, 6)
1244            .with_on_change(RefAny::new(ChangeLog { seen: Vec::new() }), cb(record_change))
1245            .with_current_page(4);
1246        assert_eq!(p.pagination_state.inner.current_page, 4);
1247        assert!(
1248            p.pagination_state.on_change.as_ref().is_some(),
1249            "with_current_page must not disturb the callback"
1250        );
1251    }
1252
1253    // ==================================================================
1254    // Pagination::swap_with_default
1255    // ==================================================================
1256
1257    #[test]
1258    fn swap_with_default_returns_the_old_value_and_resets_self() {
1259        let mut p = Pagination::create(3, 9);
1260        let old = p.swap_with_default();
1261
1262        assert_eq!(old.pagination_state.inner.current_page, 3);
1263        assert_eq!(old.pagination_state.inner.total_pages, 9);
1264        assert_eq!(p, Pagination::default(), "self must be left as a 1-of-1 pager");
1265    }
1266
1267    #[test]
1268    fn swap_with_default_moves_the_callback_out_of_self() {
1269        let mut p = Pagination::create(1, 3)
1270            .with_on_change(RefAny::new(ChangeLog { seen: Vec::new() }), cb(record_change));
1271
1272        let old = p.swap_with_default();
1273        assert!(
1274            old.pagination_state.on_change.as_ref().is_some(),
1275            "the callback must travel with the returned value"
1276        );
1277        assert!(
1278            p.pagination_state.on_change.as_ref().is_none(),
1279            "self must not keep a dangling reference to the moved-out callback"
1280        );
1281    }
1282
1283    #[test]
1284    fn swap_with_default_is_stable_when_repeated() {
1285        let mut p = Pagination::create(2, 4);
1286        let _ = p.swap_with_default();
1287        for _ in 0..100 {
1288            let out = p.swap_with_default();
1289            assert_eq!(out, Pagination::default());
1290            assert_eq!(p, Pagination::default());
1291        }
1292    }
1293
1294    #[test]
1295    fn swap_with_default_preserves_a_hand_corrupted_state_verbatim() {
1296        let mut p = Pagination::create(1, 4);
1297        p.pagination_state.inner.current_page = usize::MAX;
1298        p.pagination_state.inner.total_pages = 0;
1299
1300        let old = p.swap_with_default();
1301        assert_eq!(
1302            old.pagination_state.inner,
1303            PaginationState {
1304                current_page: usize::MAX,
1305                total_pages: 0,
1306            },
1307            "the swap must move state out untouched (no clamping/rewriting)"
1308        );
1309        assert_eq!(p.pagination_state.inner.current_page, 1);
1310        assert_eq!(p.pagination_state.inner.total_pages, 1);
1311    }
1312
1313    // ==================================================================
1314    // Pagination::set_on_change / with_on_change
1315    // ==================================================================
1316
1317    #[test]
1318    fn with_on_change_installs_the_callback_and_touches_nothing_else() {
1319        let before = Pagination::create(2, 5);
1320        let after = Pagination::create(2, 5)
1321            .with_on_change(RefAny::new(ChangeLog { seen: Vec::new() }), cb(record_change));
1322
1323        assert_eq!(
1324            after.pagination_state.inner, before.pagination_state.inner,
1325            "installing a callback must not disturb the page state"
1326        );
1327        assert_eq!(
1328            after.container_style, before.container_style,
1329            "installing a callback must not disturb the container style"
1330        );
1331
1332        let installed = after
1333            .pagination_state
1334            .on_change
1335            .as_ref()
1336            .expect("with_on_change must install Some(..)");
1337        assert_eq!(installed.callback.cb as usize, record_change as usize);
1338    }
1339
1340    #[test]
1341    fn set_on_change_overwrites_the_previous_callback_and_data() {
1342        let mut p = Pagination::create(1, 3);
1343        p.set_on_change(RefAny::new(ChangeLog { seen: Vec::new() }), cb(record_change));
1344        p.set_on_change(RefAny::new(42u32), cb(change_do_nothing));
1345
1346        let installed = p
1347            .pagination_state
1348            .on_change
1349            .as_mut()
1350            .expect("still Some after the overwrite");
1351        assert_eq!(
1352            installed.callback.cb as usize, change_do_nothing as usize,
1353            "the last set_on_change must win"
1354        );
1355        assert_eq!(
1356            installed.refany.downcast_ref::<u32>().map(|v| *v),
1357            Some(42),
1358            "the payload must be replaced along with the fn pointer"
1359        );
1360        assert!(
1361            installed.refany.downcast_ref::<ChangeLog>().is_none(),
1362            "the stale payload must be gone"
1363        );
1364    }
1365
1366    #[test]
1367    fn generic_callback_conversion_round_trips_the_fn_pointer() {
1368        // The FFI path (`From<Callback>`) transmutes the fn pointer. The value must
1369        // round-trip bit-for-bit — a corrupted pointer would be an unconditional
1370        // jump into garbage at click time. (Never invoked here.)
1371        let raw = record_change as usize;
1372        let generic = Callback {
1373            cb: unsafe { core::mem::transmute::<usize, crate::callbacks::CallbackType>(raw) },
1374            ctx: OptionRefAny::None,
1375        };
1376        let converted: PaginationOnChangeCallback = generic.into();
1377        assert_eq!(converted.cb as usize, raw);
1378    }
1379
1380    // ==================================================================
1381    // Pagination::dom
1382    // ==================================================================
1383
1384    #[test]
1385    fn dom_lays_out_prev_then_pages_then_next() {
1386        for total in [1usize, 2, 3, 10, 99] {
1387            let dom = Pagination::create(1, total).dom();
1388            let children = dom.children.as_ref();
1389
1390            assert_eq!(children.len(), total + 2, "Prev + {total} pages + Next");
1391            assert_eq!(text_of(&children[0]), Some("Prev"));
1392            assert_eq!(text_of(&children[total + 1]), Some("Next"));
1393            for (p, child) in children.iter().enumerate().take(total + 1).skip(1) {
1394                assert_eq!(
1395                    text_of(child),
1396                    Some(p.to_string().as_str()),
1397                    "page {p} must sit at sibling position {p}"
1398                );
1399            }
1400
1401            assert!(dom.root.has_class("__azul-native-pagination"));
1402            assert!(dom.root.is_node_type(NodeType::Div), "the bar must be a div");
1403            assert_eq!(classes(&children[0]), ["__azul-native-pagination-nav"]);
1404            assert_eq!(classes(&children[total + 1]), ["__azul-native-pagination-nav"]);
1405            assert_eq!(classes(&children[1]), ["__azul-native-pagination-page"]);
1406        }
1407    }
1408
1409    #[test]
1410    fn dom_page_labels_are_plain_ascii_decimal() {
1411        let total = 1000;
1412        let dom = Pagination::create(1, total).dom();
1413        let children = dom.children.as_ref();
1414        for p in [1usize, 9, 10, 99, 100, 999, 1000] {
1415            let label = text_of(&children[p]).expect("page buttons are text nodes");
1416            assert_eq!(label, p.to_string(), "page {p} label");
1417            assert!(
1418                label.bytes().all(|b| b.is_ascii_digit()),
1419                "page {p} label {label:?} is not plain decimal"
1420            );
1421        }
1422    }
1423
1424    #[test]
1425    fn dom_wires_one_mouseup_handler_per_button() {
1426        let total = 4;
1427        let dom = Pagination::create(2, total).dom();
1428        for (i, child) in dom.children.as_ref().iter().enumerate() {
1429            let cbs = child.root.get_callbacks();
1430            assert_eq!(cbs.as_ref().len(), 1, "button {i} must carry exactly one handler");
1431            assert_eq!(
1432                cbs.as_ref()[0].event,
1433                EventFilter::Hover(HoverEventFilter::MouseUp)
1434            );
1435            assert_eq!(cbs.as_ref()[0].callback.cb, on_page_click as usize);
1436            assert_eq!(
1437                child.root.get_tab_index(),
1438                Some(TabIndex::Auto),
1439                "button {i} must be keyboard-reachable"
1440            );
1441        }
1442        assert!(
1443            dom.root.get_callbacks().as_ref().is_empty(),
1444            "the container itself must not be clickable"
1445        );
1446    }
1447
1448    #[test]
1449    fn dom_shares_one_state_refany_across_every_button() {
1450        let dom = Pagination::create(1, 4).dom();
1451
1452        // Write through Prev's handle…
1453        let mut first = button_state(&dom, 0);
1454        {
1455            let mut w = first
1456                .downcast_mut::<PaginationStateWrapper>()
1457                .expect("button state must be a PaginationStateWrapper");
1458            w.inner.current_page = 3;
1459        }
1460        // …and read it back through Next's handle.
1461        let mut last = button_state(&dom, 5);
1462        assert_eq!(
1463            current_page_of(&mut last),
1464            3,
1465            "every button must observe the same shared state"
1466        );
1467    }
1468
1469    #[test]
1470    fn dom_gives_separate_pagers_separate_state() {
1471        let a = Pagination::create(1, 3).dom();
1472        let b = Pagination::create(1, 3).dom();
1473
1474        let mut a0 = button_state(&a, 0);
1475        {
1476            let mut w = a0.downcast_mut::<PaginationStateWrapper>().unwrap();
1477            w.inner.current_page = 3;
1478        }
1479
1480        let mut b0 = button_state(&b, 0);
1481        assert_eq!(
1482            current_page_of(&mut b0),
1483            1,
1484            "two pagers must not alias one another's state"
1485        );
1486    }
1487
1488    #[test]
1489    fn dom_marks_exactly_the_current_page_active() {
1490        for total in [1usize, 2, 5, 12] {
1491            for current in 1..=total {
1492                let dom = Pagination::create(current, total).dom();
1493                let children = dom.children.as_ref();
1494
1495                let active: Vec<usize> = (0..children.len())
1496                    .filter(|i| {
1497                        background_color(&inline_props(&children[*i])) == Some(ACCENT_BG_COLOR)
1498                    })
1499                    .collect();
1500                assert_eq!(
1501                    active,
1502                    vec![current],
1503                    "exactly the current page carries the accent fill (total={total})"
1504                );
1505                assert_eq!(
1506                    text_color(&inline_props(&children[current])),
1507                    Some(ACTIVE_TEXT),
1508                    "the active page must use the light text"
1509                );
1510            }
1511        }
1512    }
1513
1514    #[test]
1515    fn dom_mutes_prev_at_the_first_page_and_next_at_the_last() {
1516        let total = 5;
1517        for current in 1..=total {
1518            let dom = Pagination::create(current, total).dom();
1519            let children = dom.children.as_ref();
1520
1521            let prev = text_color(&inline_props(&children[0]));
1522            let next = text_color(&inline_props(&children[total + 1]));
1523
1524            assert_eq!(
1525                prev,
1526                Some(if current == 1 { DISABLED_TEXT } else { NEUTRAL_TEXT }),
1527                "Prev at page {current}/{total}"
1528            );
1529            assert_eq!(
1530                next,
1531                Some(if current == total { DISABLED_TEXT } else { NEUTRAL_TEXT }),
1532                "Next at page {current}/{total}"
1533            );
1534            // A muted end is a *style-only* signal — it stays clickable.
1535            assert_eq!(children[0].root.get_callbacks().as_ref().len(), 1);
1536            assert_eq!(children[total + 1].root.get_callbacks().as_ref().len(), 1);
1537        }
1538    }
1539
1540    #[test]
1541    fn dom_rounds_only_the_two_outer_ends_of_the_bar() {
1542        let total = 4;
1543        let dom = Pagination::create(1, total).dom();
1544        let children = dom.children.as_ref();
1545        let r = PAGE_RADIUS as f32;
1546
1547        assert_eq!(
1548            radii(&inline_props(&children[0])),
1549            (Some(r), Some(r), None, None),
1550            "Prev is rounded on the left only"
1551        );
1552        assert_eq!(
1553            radii(&inline_props(&children[total + 1])),
1554            (None, None, Some(r), Some(r)),
1555            "Next is rounded on the right only"
1556        );
1557        for (p, child) in children.iter().enumerate().take(total + 1).skip(1) {
1558            assert_eq!(
1559                radii(&inline_props(child)),
1560                (None, None, None, None),
1561                "interior page {p} must stay square"
1562            );
1563            assert_eq!(
1564                has_left_border(&inline_props(child)),
1565                (false, false, false),
1566                "only Prev draws a left border, so buttons share one separator"
1567            );
1568        }
1569        assert_eq!(
1570            has_left_border(&inline_props(&children[0])),
1571            (true, true, true),
1572            "Prev closes the left edge of the bar"
1573        );
1574    }
1575
1576    #[test]
1577    fn dom_carries_the_container_style_and_the_button_styles_verbatim() {
1578        let p = Pagination::create(2, 3);
1579        let dom = p.dom();
1580        assert_eq!(
1581            inline_props(&dom),
1582            PAGINATION_CONTAINER_STYLE
1583                .iter()
1584                .map(|p| p.property.clone())
1585                .collect::<Vec<_>>(),
1586            "the container style must survive the Dom round-trip"
1587        );
1588
1589        let children = dom.children.as_ref();
1590        assert_eq!(
1591            inline_props(&children[2]),
1592            declared(&build_button_style(true, false, false, false)),
1593            "the active page's style must match the builder output verbatim"
1594        );
1595        assert_eq!(
1596            inline_props(&children[0]),
1597            declared(&build_button_style(false, false, true, false)),
1598            "Prev's style must match the builder output verbatim"
1599        );
1600        // page 2 of 3, so Next is *not* at its bound and stays un-muted.
1601        assert_eq!(
1602            inline_props(&children[4]),
1603            declared(&build_button_style(false, false, false, true)),
1604            "Next's style must match the builder output verbatim"
1605        );
1606    }
1607
1608    #[test]
1609    fn dom_estimated_total_children_matches_the_real_child_count() {
1610        // `estimated_total_children` is a cached count; if it under-counts,
1611        // `convert_dom_into_compact_dom` under-allocates.
1612        for total in [1usize, 2, 3, 8, 64, 257] {
1613            let dom = Pagination::create(1, total).dom();
1614            assert_eq!(dom.children.as_ref().len(), total + 2);
1615            assert_eq!(
1616                dom.estimated_total_children,
1617                total + 2,
1618                "cached descendant count desynced for total={total}"
1619            );
1620        }
1621    }
1622
1623    #[test]
1624    fn dom_of_a_hand_zeroed_total_has_only_prev_and_next() {
1625        // `total_pages` is a pub field: zeroing it must yield an inert two-button
1626        // bar, not an empty/negative range or a panic.
1627        let mut p = Pagination::create(1, 3);
1628        p.pagination_state.inner.total_pages = 0;
1629        let dom = p.dom();
1630        let children = dom.children.as_ref();
1631
1632        assert_eq!(children.len(), 2, "no pages => just Prev and Next");
1633        assert_eq!(text_of(&children[0]), Some("Prev"));
1634        assert_eq!(text_of(&children[1]), Some("Next"));
1635        // current(1) >= total(0), so Next reads as disabled too.
1636        assert_eq!(text_color(&inline_props(&children[1])), Some(DISABLED_TEXT));
1637    }
1638
1639    #[test]
1640    fn dom_of_an_out_of_range_current_page_marks_nothing_active() {
1641        // Reachable only by writing the pub field directly. The bar must still
1642        // render deterministically instead of indexing out of bounds.
1643        let mut p = Pagination::create(1, 4);
1644        p.pagination_state.inner.current_page = 99;
1645        let dom = p.dom();
1646        let children = dom.children.as_ref();
1647
1648        assert_eq!(children.len(), 6);
1649        assert!(
1650            (0..children.len())
1651                .all(|i| background_color(&inline_props(&children[i])) == Some(NEUTRAL_BG_COLOR)),
1652            "no page matches 99, so nothing is painted active"
1653        );
1654        assert_eq!(
1655            text_color(&inline_props(&children[0])),
1656            Some(NEUTRAL_TEXT),
1657            "Prev is live (99 > 1)"
1658        );
1659        assert_eq!(
1660            text_color(&inline_props(&children[5])),
1661            Some(DISABLED_TEXT),
1662            "Next is muted (99 >= 4)"
1663        );
1664    }
1665
1666    #[test]
1667    fn dom_of_many_pages_flattens_without_panicking() {
1668        let total = 500;
1669        let styled = StyledDom::create_from_dom(Pagination::create(250, total).dom());
1670        assert_eq!(
1671            styled.node_hierarchy.as_ref().len(),
1672            total + 3,
1673            "root + Prev + {total} pages + Next"
1674        );
1675    }
1676
1677    #[test]
1678    fn from_pagination_for_dom_equals_dom() {
1679        let dom: Dom = Pagination::create(2, 3).into();
1680        assert_eq!(dom.children.as_ref().len(), 5);
1681        assert_eq!(text_of(&dom.children.as_ref()[0]), Some("Prev"));
1682    }
1683
1684    // ==================================================================
1685    // on_page_click
1686    // ==================================================================
1687
1688    #[test]
1689    fn click_next_advances_exactly_one_page() {
1690        let total = 5;
1691        let (styled, state) = flatten(Pagination::create(2, total));
1692        let mut state2 = state.clone();
1693
1694        let (update, changes) = run_click(Some(styled), next_node(total), state);
1695        assert_eq!(update, Update::DoNothing, "no user callback => nothing to redraw");
1696        assert_eq!(current_page_of(&mut state2), 3, "Next must step 2 -> 3");
1697        assert_eq!(
1698            restyle(&changes).len(),
1699            total + 2,
1700            "every button is restyled after a real change"
1701        );
1702    }
1703
1704    #[test]
1705    fn click_prev_steps_back_exactly_one_page() {
1706        let total = 5;
1707        let (styled, state) = flatten(Pagination::create(4, total));
1708        let mut state2 = state.clone();
1709
1710        let (_, _) = run_click(Some(styled), PREV_NODE, state);
1711        assert_eq!(current_page_of(&mut state2), 3, "Prev must step 4 -> 3");
1712    }
1713
1714    #[test]
1715    fn click_a_page_number_jumps_straight_to_it() {
1716        let total = 8;
1717        for target in [1usize, 2, 7, 8] {
1718            let (styled, state) = flatten(Pagination::create(4, total));
1719            let mut state2 = state.clone();
1720
1721            let (_, changes) = run_click(Some(styled), page_node(target), state);
1722            assert_eq!(
1723                current_page_of(&mut state2),
1724                target,
1725                "page button {target} sits at sibling position {target}"
1726            );
1727            assert!(!changes.is_empty(), "a real jump must restyle the bar");
1728        }
1729    }
1730
1731    #[test]
1732    fn click_the_current_page_is_a_no_op() {
1733        let total = 6;
1734        let (styled, state) = flatten(Pagination::create(3, total));
1735        let mut state2 = state.clone();
1736
1737        let (update, changes) = run_click(Some(styled), page_node(3), state);
1738        assert_eq!(update, Update::DoNothing);
1739        assert!(
1740            changes.is_empty(),
1741            "an unchanged page must not push a restyle transaction"
1742        );
1743        assert_eq!(current_page_of(&mut state2), 3);
1744    }
1745
1746    #[test]
1747    fn click_prev_at_the_first_page_is_a_no_op() {
1748        let total = 4;
1749        let (styled, state) = flatten(Pagination::create(1, total));
1750        let mut state2 = state.clone();
1751
1752        let (update, changes) = run_click(Some(styled), PREV_NODE, state);
1753        assert_eq!(update, Update::DoNothing);
1754        assert!(changes.is_empty(), "a disabled end must fire nothing at all");
1755        assert_eq!(current_page_of(&mut state2), 1, "page 1 must not underflow to 0");
1756    }
1757
1758    #[test]
1759    fn click_next_at_the_last_page_is_a_no_op() {
1760        let total = 4;
1761        let (styled, state) = flatten(Pagination::create(total, total));
1762        let mut state2 = state.clone();
1763
1764        let (update, changes) = run_click(Some(styled), next_node(total), state);
1765        assert_eq!(update, Update::DoNothing);
1766        assert!(changes.is_empty());
1767        assert_eq!(current_page_of(&mut state2), total, "must not run past the end");
1768    }
1769
1770    #[test]
1771    fn click_walks_the_whole_range_without_escaping_its_bounds() {
1772        let total = 6;
1773        let (styled, state) = flatten(Pagination::create(1, total));
1774        let mut probe = state.clone();
1775
1776        // Press Next more often than there are pages.
1777        for step in 0..(total + 2) {
1778            let (_, _) = run_click(Some(styled.clone()), next_node(total), state.clone());
1779            let page = current_page_of(&mut probe);
1780            assert!(
1781                (1..=total).contains(&page),
1782                "page {page} escaped [1, {total}] after {step} Next presses"
1783            );
1784            assert_eq!(page, (step + 2).min(total), "Next must advance one at a time");
1785        }
1786        assert_eq!(current_page_of(&mut probe), total);
1787
1788        // …then all the way back down.
1789        for step in 0..(total + 2) {
1790            let (_, _) = run_click(Some(styled.clone()), PREV_NODE, state.clone());
1791            let page = current_page_of(&mut probe);
1792            assert!((1..=total).contains(&page), "page {page} escaped [1, {total}]");
1793            assert_eq!(page, total.saturating_sub(step + 1).max(1));
1794        }
1795        assert_eq!(current_page_of(&mut probe), 1);
1796    }
1797
1798    #[test]
1799    fn click_on_a_one_page_pager_is_completely_inert() {
1800        let (styled, state) = flatten(Pagination::create(1, 1));
1801        let mut probe = state.clone();
1802
1803        for hit in [PREV_NODE, page_node(1), next_node(1)] {
1804            let (update, changes) = run_click(Some(styled.clone()), hit, state.clone());
1805            assert_eq!(update, Update::DoNothing, "node {hit} on a 1-page pager");
1806            assert!(changes.is_empty(), "node {hit} must not restyle anything");
1807            assert_eq!(current_page_of(&mut probe), 1);
1808        }
1809    }
1810
1811    #[test]
1812    fn click_on_a_hand_zeroed_pager_is_inert() {
1813        // total_pages = 0 => children are just [Prev, Next] (n == 2, total == 0).
1814        // The `n < 2` guard is not hit, so both ends must fall through the bounds
1815        // checks instead of computing a 0/underflowing page.
1816        let mut p = Pagination::create(1, 3);
1817        p.pagination_state.inner.total_pages = 0;
1818        let (styled, state) = flatten(p);
1819        let mut probe = state.clone();
1820
1821        for hit in [1usize, 2] {
1822            let (update, changes) = run_click(Some(styled.clone()), hit, state.clone());
1823            assert_eq!(update, Update::DoNothing, "node {hit} on a 0-page pager");
1824            assert!(changes.is_empty());
1825            assert_eq!(current_page_of(&mut probe), 1);
1826        }
1827    }
1828
1829    #[test]
1830    fn click_invokes_the_user_callback_with_the_new_state() {
1831        let total = 5;
1832        let mut log = RefAny::new(ChangeLog { seen: Vec::new() });
1833        let p = Pagination::create(1, total).with_on_change(log.clone(), cb(record_change));
1834        let (styled, state) = flatten(p);
1835
1836        let (update, _) = run_click(Some(styled.clone()), page_node(4), state.clone());
1837        assert_eq!(update, Update::RefreshDom, "the user's Update must propagate");
1838        assert_eq!(
1839            logged(&mut log),
1840            vec![PaginationState {
1841                current_page: 4,
1842                total_pages: total,
1843            }],
1844            "the callback sees the *new* page, with total_pages intact"
1845        );
1846
1847        // A no-op click must not fire the callback again.
1848        let (update, _) = run_click(Some(styled.clone()), page_node(4), state.clone());
1849        assert_eq!(update, Update::DoNothing);
1850        assert_eq!(logged(&mut log).len(), 1, "an unchanged page fires nothing");
1851
1852        let (_, _) = run_click(Some(styled), PREV_NODE, state);
1853        assert_eq!(
1854            logged(&mut log).len(),
1855            2,
1856            "a real change fires the callback again"
1857        );
1858        assert_eq!(logged(&mut log)[1].current_page, 3);
1859    }
1860
1861    #[test]
1862    fn click_propagates_every_update_variant_unchanged() {
1863        for (callback, expected) in [
1864            (cb(change_do_nothing), Update::DoNothing),
1865            (cb(change_refresh_all), Update::RefreshDomAllWindows),
1866        ] {
1867            let p = Pagination::create(1, 3).with_on_change(RefAny::new(0u8), callback);
1868            let (styled, state) = flatten(p);
1869            let (update, _) = run_click(Some(styled), page_node(2), state);
1870            assert_eq!(update, expected);
1871        }
1872    }
1873
1874    #[test]
1875    fn click_restyles_every_button_and_marks_only_the_new_page() {
1876        let total = 5;
1877        let new_page = 4;
1878        let (styled, state) = flatten(Pagination::create(1, total));
1879        let (_, changes) = run_click(Some(styled), page_node(new_page), state);
1880
1881        let pass = restyle(&changes);
1882        assert_eq!(pass.len(), total + 2, "one pair of writes per button");
1883
1884        for (i, (node, bg, fg)) in pass.iter().enumerate() {
1885            assert_eq!(*node, i + 1, "buttons must be restyled in document order");
1886            let (want_bg, want_fg) = if i == 0 {
1887                // Prev: live, because the new page is not 1.
1888                (NEUTRAL_BG_COLOR, NEUTRAL_TEXT)
1889            } else if i == total + 1 {
1890                // Next: live, because the new page is not the last.
1891                (NEUTRAL_BG_COLOR, NEUTRAL_TEXT)
1892            } else if i == new_page {
1893                (ACCENT_BG_COLOR, ACTIVE_TEXT)
1894            } else {
1895                (NEUTRAL_BG_COLOR, NEUTRAL_TEXT)
1896            };
1897            assert_eq!((*bg, *fg), (want_bg, want_fg), "button {i} after the jump");
1898        }
1899    }
1900
1901    #[test]
1902    fn click_restyle_mutes_the_end_it_lands_on() {
1903        let total = 4;
1904
1905        // Landing on page 1 must mute Prev…
1906        let (styled, state) = flatten(Pagination::create(3, total));
1907        let (_, changes) = run_click(Some(styled), page_node(1), state);
1908        let pass = restyle(&changes);
1909        assert_eq!(pass[0].2, DISABLED_TEXT, "Prev must go muted at page 1");
1910        assert_eq!(pass[total + 1].2, NEUTRAL_TEXT, "Next stays live at page 1");
1911
1912        // …and landing on the last page must mute Next.
1913        let (styled, state) = flatten(Pagination::create(1, total));
1914        let (_, changes) = run_click(Some(styled), page_node(total), state);
1915        let pass = restyle(&changes);
1916        assert_eq!(pass[0].2, NEUTRAL_TEXT, "Prev is live at the last page");
1917        assert_eq!(pass[total + 1].2, DISABLED_TEXT, "Next must go muted at the end");
1918    }
1919
1920    #[test]
1921    fn click_on_the_root_node_does_nothing() {
1922        // The root has no parent -> the handler must bail before touching anything.
1923        let (styled, state) = flatten(Pagination::create(2, 4));
1924        let mut probe = state.clone();
1925
1926        let (update, changes) = run_click(Some(styled), 0, state);
1927        assert_eq!(update, Update::DoNothing);
1928        assert!(changes.is_empty());
1929        assert_eq!(current_page_of(&mut probe), 2, "state must be untouched");
1930    }
1931
1932    #[test]
1933    fn click_on_an_out_of_range_node_does_nothing() {
1934        let (styled, state) = flatten(Pagination::create(2, 4));
1935        let mut probe = state.clone();
1936
1937        let (update, changes) = run_click(Some(styled), 9999, state);
1938        assert_eq!(
1939            update,
1940            Update::DoNothing,
1941            "a hit node that isn't in the tree must not panic"
1942        );
1943        assert!(changes.is_empty());
1944        assert_eq!(current_page_of(&mut probe), 2);
1945    }
1946
1947    #[test]
1948    fn click_with_no_layout_result_does_nothing() {
1949        let dom = Pagination::create(2, 4).dom();
1950        let state = button_state(&dom, 0);
1951
1952        let (update, changes) = run_click(None, PREV_NODE, state);
1953        assert_eq!(
1954            update,
1955            Update::DoNothing,
1956            "an empty LayoutWindow must be handled, not unwrapped"
1957        );
1958        assert!(changes.is_empty());
1959    }
1960
1961    #[test]
1962    fn click_with_a_foreign_payload_does_nothing() {
1963        let (styled, _) = flatten(Pagination::create(2, 4));
1964        let (update, changes) = run_click(Some(styled), page_node(3), RefAny::new(0u32));
1965        assert_eq!(update, Update::DoNothing, "a failed downcast must bail cleanly");
1966        assert!(
1967            changes.is_empty(),
1968            "no state change => no restyle, even for a foreign payload"
1969        );
1970    }
1971
1972    #[test]
1973    fn click_with_the_state_already_borrowed_does_nothing() {
1974        let (styled, state) = flatten(Pagination::create(2, 4));
1975
1976        // A live mutable borrow on a sibling clone: the handler's own `downcast_ref`
1977        // must fail (returning DoNothing) instead of aliasing `&mut`.
1978        let mut held = state.clone();
1979        let guard = held
1980            .downcast_mut::<PaginationStateWrapper>()
1981            .expect("first borrow succeeds");
1982
1983        let (update, changes) = run_click(Some(styled), page_node(3), state);
1984        assert_eq!(update, Update::DoNothing);
1985        assert!(changes.is_empty());
1986        drop(guard);
1987    }
1988
1989    #[test]
1990    fn click_derives_the_page_count_from_the_live_dom_not_from_the_state() {
1991        // The doc promises the handler "stays correct regardless of total_pages
1992        // drift": it reads the child count, so a stale `total_pages` in the state
1993        // must not change where Next stops.
1994        let total = 3;
1995        let dom = Pagination::create(1, total).dom();
1996        let mut state = button_state(&dom, 0);
1997
1998        // Corrupt the *shared* state after the DOM was built: the rendered bar
1999        // still has 3 page buttons.
2000        {
2001            let mut w = state
2002                .downcast_mut::<PaginationStateWrapper>()
2003                .expect("the shared payload is a PaginationStateWrapper");
2004            w.inner.total_pages = 999;
2005        }
2006
2007        let styled = StyledDom::create_from_dom(dom);
2008        let mut probe = state.clone();
2009
2010        for _ in 0..(total + 3) {
2011            let (_, _) = run_click(Some(styled.clone()), next_node(total), state.clone());
2012        }
2013        assert_eq!(
2014            current_page_of(&mut probe),
2015            total,
2016            "Next must stop at the last *rendered* page, not at the stale total"
2017        );
2018        assert_eq!(
2019            state
2020                .downcast_ref::<PaginationStateWrapper>()
2021                .expect("still a PaginationStateWrapper")
2022                .inner
2023                .total_pages,
2024            999,
2025            "the handler must not rewrite total_pages behind the caller's back"
2026        );
2027    }
2028
2029    #[test]
2030    fn click_prev_from_an_out_of_range_page_steps_down_by_one() {
2031        // Only reachable by writing the pub `current_page` field. The handler has no
2032        // clamp of its own, so it walks down one step at a time rather than snapping
2033        // back into range — assert that documented-by-construction behaviour rather
2034        // than a silent repair.
2035        let total = 4;
2036        let mut p = Pagination::create(1, total);
2037        p.pagination_state.inner.current_page = 99;
2038        let (styled, state) = flatten(p);
2039        let mut probe = state.clone();
2040
2041        let (update, changes) = run_click(Some(styled.clone()), PREV_NODE, state.clone());
2042        assert_eq!(update, Update::DoNothing, "no callback installed");
2043        assert_eq!(current_page_of(&mut probe), 98, "Prev steps 99 -> 98");
2044        // Nothing is in range, so the restyle marks no page active.
2045        let pass = restyle(&changes);
2046        assert_eq!(pass.len(), total + 2);
2047        assert!(
2048            pass.iter().all(|(_, bg, _)| *bg == NEUTRAL_BG_COLOR),
2049            "an out-of-range page cannot be painted active"
2050        );
2051
2052        // Next, by contrast, is a no-op: 98 is already past the last page.
2053        let (update, changes) = run_click(Some(styled), next_node(total), state);
2054        assert_eq!(update, Update::DoNothing);
2055        assert!(changes.is_empty());
2056        assert_eq!(current_page_of(&mut probe), 98);
2057    }
2058
2059    #[test]
2060    fn click_a_page_button_snaps_an_out_of_range_page_back_into_range() {
2061        let total = 4;
2062        let mut p = Pagination::create(1, total);
2063        p.pagination_state.inner.current_page = usize::MAX;
2064        let (styled, state) = flatten(p);
2065        let mut probe = state.clone();
2066
2067        let (_, _) = run_click(Some(styled), page_node(2), state);
2068        assert_eq!(
2069            current_page_of(&mut probe),
2070            2,
2071            "an explicit page click always lands in range"
2072        );
2073    }
2074}