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}