1use 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
53pub 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#[derive(Debug, Clone, PartialEq, Eq)]
78#[repr(C)]
79pub struct Pagination {
80 pub pagination_state: PaginationStateWrapper,
81 pub container_style: CssPropertyWithConditionsVec,
83}
84
85#[derive(Debug, Default, Clone, PartialEq, Eq)]
86#[repr(C)]
87pub struct PaginationStateWrapper {
88 pub inner: PaginationState,
90 pub on_change: OptionPaginationOnChange,
92}
93
94#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
96#[repr(C)]
97pub struct PaginationState {
98 pub current_page: usize,
100 pub total_pages: usize,
102}
103
104const PAGE_BORDER_COLOR: ColorU = ColorU { r: 206, g: 212, b: 218, a: 255 };
107const ACCENT_BG_COLOR: ColorU = ColorU { r: 13, g: 110, b: 253, a: 255 };
109const NEUTRAL_BG_COLOR: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
111const ACTIVE_TEXT: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
113const NEUTRAL_TEXT: ColorU = ColorU { r: 33, g: 37, b: 41, a: 255 };
115const 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
129static 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#[allow(clippy::too_many_lines)] #[allow(clippy::fn_params_excessive_bools)] fn 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 CssPropertyWithConditions::simple(CssProperty::const_min_width(LayoutMinWidth::const_px(
173 36,
174 ))),
175 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 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 #[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 #[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 #[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 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 children.push(make_button(
377 PREV_LABEL,
378 PAGINATION_NAV_CLASS,
379 build_button_style(false, current <= 1, true, false),
380 ));
381
382 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 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
411extern "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 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 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 if current > 1 {
451 current - 1
452 } else {
453 current
454 }
455 } else if pos == n - 1 {
456 if current < total {
458 current + 1
459 } else {
460 current
461 }
462 } else {
463 pos
465 };
466
467 if new_page == current {
468 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 for (i, node) in buttons.iter().enumerate() {
490 let (bg, text) = if i == 0 {
491 let disabled = new_page <= 1;
493 (NEUTRAL_BG, if disabled { DISABLED_TEXT } else { NEUTRAL_TEXT })
494 } else if i == n - 1 {
495 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 fn declared(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
559 v.as_ref().iter().map(|p| p.property.clone()).collect()
560 }
561
562 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 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 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 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 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 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 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 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 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 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 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 const PREV_NODE: usize = 1;
767 const fn page_node(p: usize) -> usize {
769 p + 1
770 }
771 const fn next_node(total: usize) -> usize {
773 total + 2
774 }
775
776 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 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 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: ¤t_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 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 #[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 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 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 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 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 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); 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 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 #[test]
1090 fn create_clamps_current_page_into_range() {
1091 for (cur, total, want_cur, want_total) in [
1092 (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 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 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 #[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 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 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 #[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 #[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 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 #[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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 (NEUTRAL_BG_COLOR, NEUTRAL_TEXT)
1889 } else if i == total + 1 {
1890 (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 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 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 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 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 let total = 3;
1995 let dom = Pagination::create(1, total).dom();
1996 let mut state = button_state(&dom, 0);
1997
1998 {
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 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 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 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}