1use std::vec::Vec;
25
26use azul_core::{
27 callbacks::{CoreCallbackData, Update},
28 dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
29 refany::RefAny,
30};
31use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
32use azul_css::{
33 props::{
34 basic::{color::ColorU, PixelValue, StyleFontSize},
35 layout::{LayoutDisplay, LayoutFlexDirection, LayoutAlignItems, LayoutFlexGrow, LayoutFlexBasis, LayoutWidth, LayoutJustifyContent, LayoutHeight, LayoutMinWidth, LayoutPaddingTop},
36 property::{CssProperty, LayoutFlexBasisValue, LayoutWidthValue},
37 style::{StyleBackgroundContent, StyleBackgroundContentVec, StyleCursor, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius, StyleTextAlign, StyleUserSelect, StyleTextColor},
38 },
39 impl_option_inner, AzString, StringVec,
40};
41
42use crate::callbacks::CallbackInfo;
43
44static STEPPER_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str("__azul-native-stepper"))];
45static STEPPER_STEP_CLASS: &[IdOrClass] =
46 &[Class(AzString::from_const_str("__azul-native-stepper-step"))];
47static STEPPER_ROW_CLASS: &[IdOrClass] =
48 &[Class(AzString::from_const_str("__azul-native-stepper-row"))];
49static STEPPER_CIRCLE_CLASS: &[IdOrClass] =
50 &[Class(AzString::from_const_str("__azul-native-stepper-circle"))];
51static STEPPER_CONNECTOR_CLASS: &[IdOrClass] =
52 &[Class(AzString::from_const_str("__azul-native-stepper-connector"))];
53static STEPPER_LABEL_CLASS: &[IdOrClass] =
54 &[Class(AzString::from_const_str("__azul-native-stepper-label"))];
55
56pub type StepperOnStepChangeCallbackType =
58 extern "C" fn(RefAny, CallbackInfo, StepperState) -> Update;
59impl_widget_callback!(
60 StepperOnStepChange,
61 OptionStepperOnStepChange,
62 StepperOnStepChangeCallback,
63 StepperOnStepChangeCallbackType
64);
65
66azul_core::impl_managed_callback! {
67 wrapper: StepperOnStepChangeCallback,
68 info_ty: CallbackInfo,
69 return_ty: Update,
70 default_ret: Update::DoNothing,
71 invoker_static: STEPPER_ON_STEP_CHANGE_INVOKER,
72 invoker_ty: AzStepperOnStepChangeCallbackInvoker,
73 thunk_fn: az_stepper_on_step_change_callback_thunk,
74 setter_fn: AzApp_setStepperOnStepChangeCallbackInvoker,
75 from_handle_fn: AzStepperOnStepChangeCallback_createFromHostHandle,
76 extra_args: [ state: StepperState ],
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81#[repr(C)]
82pub struct Stepper {
83 pub stepper_state: StepperStateWrapper,
84 pub labels: StringVec,
86 pub container_style: CssPropertyWithConditionsVec,
88}
89
90#[derive(Debug, Default, Clone, PartialEq, Eq)]
91#[repr(C)]
92pub struct StepperStateWrapper {
93 pub inner: StepperState,
95 pub on_step_change: OptionStepperOnStepChange,
97}
98
99#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
101#[repr(C)]
102pub struct StepperState {
103 pub current_step: usize,
105 pub total_steps: usize,
107}
108
109const ACCENT_COLOR: ColorU = ColorU { r: 13, g: 110, b: 253, a: 255 };
112const WHITE: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
114const MUTED_CIRCLE_COLOR: ColorU = ColorU { r: 233, g: 236, b: 239, a: 255 };
116const MUTED_TEXT_COLOR: ColorU = ColorU { r: 134, g: 142, b: 150, a: 255 };
118const DARK_TEXT_COLOR: ColorU = ColorU { r: 33, g: 37, b: 41, a: 255 };
120const CONNECTOR_MUTED_COLOR: ColorU = ColorU { r: 206, g: 212, b: 218, a: 255 };
122const TRANSPARENT_COLOR: ColorU = ColorU { r: 0, g: 0, b: 0, a: 0 };
124
125const ACCENT_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(ACCENT_COLOR)];
126const ACCENT_BG: StyleBackgroundContentVec =
127 StyleBackgroundContentVec::from_const_slice(ACCENT_BG_ITEMS);
128const MUTED_CIRCLE_BG_ITEMS: &[StyleBackgroundContent] =
129 &[StyleBackgroundContent::Color(MUTED_CIRCLE_COLOR)];
130const MUTED_CIRCLE_BG: StyleBackgroundContentVec =
131 StyleBackgroundContentVec::from_const_slice(MUTED_CIRCLE_BG_ITEMS);
132const CONNECTOR_MUTED_BG_ITEMS: &[StyleBackgroundContent] =
133 &[StyleBackgroundContent::Color(CONNECTOR_MUTED_COLOR)];
134const CONNECTOR_MUTED_BG: StyleBackgroundContentVec =
135 StyleBackgroundContentVec::from_const_slice(CONNECTOR_MUTED_BG_ITEMS);
136const TRANSPARENT_BG_ITEMS: &[StyleBackgroundContent] =
137 &[StyleBackgroundContent::Color(TRANSPARENT_COLOR)];
138const TRANSPARENT_BG: StyleBackgroundContentVec =
139 StyleBackgroundContentVec::from_const_slice(TRANSPARENT_BG_ITEMS);
140
141const CIRCLE_SIZE: isize = 28;
142const CIRCLE_RADIUS: isize = 14;
143const CONNECTOR_HEIGHT: isize = 2;
144
145#[derive(Copy, Clone)]
147enum ConnFill {
148 Accent,
150 Muted,
152 Hidden,
154}
155
156impl ConnFill {
157 const fn bg(self) -> StyleBackgroundContentVec {
158 match self {
159 Self::Accent => ACCENT_BG,
160 Self::Muted => CONNECTOR_MUTED_BG,
161 Self::Hidden => TRANSPARENT_BG,
162 }
163 }
164}
165
166static STEPPER_CONTAINER_STYLE: &[CssPropertyWithConditions] = &[
168 CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
169 CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)),
170 CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Start)),
171 CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
172];
173
174static STEPPER_STEP_STYLE: &[CssPropertyWithConditions] = &[
177 CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
178 CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Column)),
179 CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
180 CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
181 CssPropertyWithConditions::simple(CssProperty::FlexBasis(LayoutFlexBasisValue::Exact(
182 LayoutFlexBasis::Exact(PixelValue::const_px(0)),
183 ))),
184 CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
185];
186
187fn row_style() -> CssPropertyWithConditionsVec {
190 CssPropertyWithConditionsVec::from_vec(vec![
191 CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
192 CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
193 LayoutFlexDirection::Row,
194 )),
195 CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
196 CssPropertyWithConditions::simple(CssProperty::Width(LayoutWidthValue::Exact(
198 LayoutWidth::Px(PixelValue::percent(100.0)),
199 ))),
200 ])
201}
202
203fn circle_style(reached: bool) -> CssPropertyWithConditionsVec {
206 let (bg, text) = if reached {
207 (ACCENT_BG, WHITE)
208 } else {
209 (MUTED_CIRCLE_BG, MUTED_TEXT_COLOR)
210 };
211 CssPropertyWithConditionsVec::from_vec(vec![
212 CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
213 CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
214 LayoutFlexDirection::Row,
215 )),
216 CssPropertyWithConditions::simple(CssProperty::const_justify_content(
217 LayoutJustifyContent::Center,
218 )),
219 CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
220 CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
221 CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(
222 CIRCLE_SIZE,
223 ))),
224 CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(
225 CIRCLE_SIZE,
226 ))),
227 CssPropertyWithConditions::simple(CssProperty::const_min_width(LayoutMinWidth::const_px(
228 CIRCLE_SIZE,
229 ))),
230 CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
231 StyleBorderTopLeftRadius::const_px(CIRCLE_RADIUS),
232 )),
233 CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
234 StyleBorderTopRightRadius::const_px(CIRCLE_RADIUS),
235 )),
236 CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
237 StyleBorderBottomLeftRadius::const_px(CIRCLE_RADIUS),
238 )),
239 CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
240 StyleBorderBottomRightRadius::const_px(CIRCLE_RADIUS),
241 )),
242 CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
243 CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
244 CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
245 CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
246 CssPropertyWithConditions::simple(CssProperty::const_background_content(bg)),
247 CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
248 inner: text,
249 })),
250 ])
251}
252
253fn connector_style(fill: ConnFill) -> CssPropertyWithConditionsVec {
255 CssPropertyWithConditionsVec::from_vec(vec![
256 CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
257 CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(
258 CONNECTOR_HEIGHT,
259 ))),
260 CssPropertyWithConditions::simple(CssProperty::const_background_content(fill.bg())),
261 ])
262}
263
264fn label_style(reached: bool) -> CssPropertyWithConditionsVec {
266 let text = if reached { DARK_TEXT_COLOR } else { MUTED_TEXT_COLOR };
267 CssPropertyWithConditionsVec::from_vec(vec![
268 CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(12))),
269 CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
270 CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
271 CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
272 CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
273 6,
274 ))),
275 CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
276 inner: text,
277 })),
278 ])
279}
280
281const fn conn_left_fill(i: usize, current: usize) -> ConnFill {
283 if i == 0 {
284 ConnFill::Hidden
285 } else if i <= current {
286 ConnFill::Accent
287 } else {
288 ConnFill::Muted
289 }
290}
291
292const fn conn_right_fill(i: usize, last: usize, current: usize) -> ConnFill {
294 if i == last {
295 ConnFill::Hidden
296 } else if i < current {
297 ConnFill::Accent
298 } else {
299 ConnFill::Muted
300 }
301}
302
303impl Stepper {
304 #[must_use] pub fn create(labels: StringVec) -> Self {
306 let total_steps = labels.as_ref().len();
307 Self {
308 stepper_state: StepperStateWrapper {
309 inner: StepperState {
310 current_step: 0,
311 total_steps,
312 },
313 ..Default::default()
314 },
315 labels,
316 container_style: CssPropertyWithConditionsVec::from_const_slice(
317 STEPPER_CONTAINER_STYLE,
318 ),
319 }
320 }
321
322 #[inline]
324 pub fn set_current_step(&mut self, current_step: usize) {
325 let total = self.stepper_state.inner.total_steps;
326 self.stepper_state.inner.current_step = if total == 0 {
327 0
328 } else {
329 current_step.min(total - 1)
330 };
331 }
332
333 #[inline]
335 #[must_use] pub fn with_current_step(mut self, current_step: usize) -> Self {
336 self.set_current_step(current_step);
337 self
338 }
339
340 #[inline]
341 #[must_use] pub fn swap_with_default(&mut self) -> Self {
342 let mut s = Self::create(StringVec::from_const_slice(&[]));
343 core::mem::swap(&mut s, self);
344 s
345 }
346
347 #[inline]
348 pub fn set_on_step_change<C: Into<StepperOnStepChangeCallback>>(
349 &mut self,
350 data: RefAny,
351 on_step_change: C,
352 ) {
353 self.stepper_state.on_step_change = Some(StepperOnStepChange {
354 callback: on_step_change.into(),
355 refany: data,
356 })
357 .into();
358 }
359
360 #[inline]
361 #[must_use] pub fn with_on_step_change<C: Into<StepperOnStepChangeCallback>>(
362 mut self,
363 data: RefAny,
364 on_step_change: C,
365 ) -> Self {
366 self.set_on_step_change(data, on_step_change);
367 self
368 }
369
370 #[must_use] pub fn dom(self) -> Dom {
371 use azul_core::{
372 callbacks::CoreCallback,
373 dom::{EventFilter, HoverEventFilter},
374 refany::OptionRefAny,
375 };
376
377 let current = self.stepper_state.inner.current_step;
378 let count = self.labels.as_ref().len();
379 let last = count.saturating_sub(1);
380
381 let state = RefAny::new(self.stepper_state);
384
385 let mut children: Vec<Dom> = Vec::with_capacity(count);
386 for (i, label) in self.labels.as_ref().iter().enumerate() {
387 let reached = i <= current;
388
389 let row = Dom::create_div()
391 .with_ids_and_classes(IdOrClassVec::from_const_slice(STEPPER_ROW_CLASS))
392 .with_css_props(row_style())
393 .with_children(
394 vec![
395 Dom::create_div()
396 .with_ids_and_classes(IdOrClassVec::from_const_slice(
397 STEPPER_CONNECTOR_CLASS,
398 ))
399 .with_css_props(connector_style(conn_left_fill(i, current))),
400 Dom::create_text(AzString::from(format!("{}", i + 1).as_str()))
401 .with_ids_and_classes(IdOrClassVec::from_const_slice(
402 STEPPER_CIRCLE_CLASS,
403 ))
404 .with_css_props(circle_style(reached)),
405 Dom::create_div()
406 .with_ids_and_classes(IdOrClassVec::from_const_slice(
407 STEPPER_CONNECTOR_CLASS,
408 ))
409 .with_css_props(connector_style(conn_right_fill(i, last, current))),
410 ]
411 .into(),
412 );
413
414 let cell = Dom::create_div()
415 .with_ids_and_classes(IdOrClassVec::from_const_slice(STEPPER_STEP_CLASS))
416 .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
417 STEPPER_STEP_STYLE,
418 ))
419 .with_callbacks(
420 vec![CoreCallbackData {
421 event: EventFilter::Hover(HoverEventFilter::MouseUp),
422 callback: CoreCallback {
423 cb: on_step_click as usize,
424 ctx: OptionRefAny::None,
425 },
426 refany: state.clone(),
427 }]
428 .into(),
429 )
430 .with_tab_index(TabIndex::Auto)
431 .with_children(
432 vec![
433 row,
434 Dom::create_text(label.clone())
435 .with_ids_and_classes(IdOrClassVec::from_const_slice(
436 STEPPER_LABEL_CLASS,
437 ))
438 .with_css_props(label_style(reached)),
439 ]
440 .into(),
441 );
442
443 children.push(cell);
444 }
445
446 Dom::create_div()
447 .with_ids_and_classes(IdOrClassVec::from_const_slice(STEPPER_CLASS))
448 .with_css_props(self.container_style)
449 .with_children(children.into())
450 }
451}
452
453impl Default for Stepper {
454 fn default() -> Self {
455 Self::create(StringVec::from_const_slice(&[]))
456 }
457}
458
459extern "C" fn on_step_click(mut data: RefAny, mut info: CallbackInfo) -> Update {
464 use azul_core::dom::DomNodeId;
465
466 let clicked = info.get_hit_node();
467 let Some(parent) = info.get_parent(clicked) else {
468 return Update::DoNothing;
469 };
470
471 let mut cells: Vec<DomNodeId> = Vec::new();
473 let mut cur = info.get_first_child(parent);
474 while let Some(node) = cur {
475 cells.push(node);
476 cur = info.get_next_sibling(node);
477 }
478 let count = cells.len();
479 if count == 0 {
480 return Update::DoNothing;
481 }
482 let last = count - 1;
483
484 let Some(clicked_idx) = cells.iter().position(|n| *n == clicked) else {
485 return Update::DoNothing;
486 };
487
488 let current = {
489 let Some(st) = data.downcast_ref::<StepperStateWrapper>() else {
490 return Update::DoNothing;
491 };
492 st.inner.current_step
493 };
494 if clicked_idx == current {
495 return Update::DoNothing;
497 }
498
499 let result = {
500 let Some(mut st) = data.downcast_mut::<StepperStateWrapper>() else {
501 return Update::DoNothing;
502 };
503 st.inner.current_step = clicked_idx;
504 let inner = st.inner;
505 let st = &mut *st;
506 match st.on_step_change.as_mut() {
507 Some(StepperOnStepChange { callback, refany }) => {
508 (callback.cb)(refany.clone(), info, inner)
509 }
510 None => Update::DoNothing,
511 }
512 };
513
514 for (i, cell) in cells.iter().enumerate() {
517 let reached = i <= clicked_idx;
518
519 let Some(row) = info.get_first_child(*cell) else {
520 continue;
521 };
522 let conn_left = info.get_first_child(row);
523 let circle = conn_left.and_then(|cl| info.get_next_sibling(cl));
524 let conn_right = circle.and_then(|c| info.get_next_sibling(c));
525 let label = info.get_next_sibling(row);
526
527 if let Some(circle) = circle {
528 let (bg, text) = if reached {
529 (ACCENT_BG, WHITE)
530 } else {
531 (MUTED_CIRCLE_BG, MUTED_TEXT_COLOR)
532 };
533 info.set_css_property(circle, CssProperty::const_background_content(bg));
534 info.set_css_property(
535 circle,
536 CssProperty::const_text_color(StyleTextColor { inner: text }),
537 );
538 }
539 if let Some(cl) = conn_left {
540 info.set_css_property(
541 cl,
542 CssProperty::const_background_content(conn_left_fill(i, clicked_idx).bg()),
543 );
544 }
545 if let Some(cr) = conn_right {
546 info.set_css_property(
547 cr,
548 CssProperty::const_background_content(conn_right_fill(i, last, clicked_idx).bg()),
549 );
550 }
551 if let Some(label) = label {
552 let text = if reached { DARK_TEXT_COLOR } else { MUTED_TEXT_COLOR };
553 info.set_css_property(
554 label,
555 CssProperty::const_text_color(StyleTextColor { inner: text }),
556 );
557 }
558 }
559
560 result
561}
562
563impl From<Stepper> for Dom {
564 fn from(s: Stepper) -> Self {
565 s.dom()
566 }
567}
568
569#[cfg(test)]
570mod autotest_generated {
571 use std::{
572 collections::{BTreeMap, HashMap, HashSet},
573 sync::{Arc, Mutex},
574 };
575
576 use azul_core::{
577 dom::{DomId, DomNodeId, EventFilter, HoverEventFilter, NodeId, NodeType},
578 geom::{LogicalRect, OptionLogicalPosition},
579 gl::OptionGlContextPtr,
580 hit_test::ScrollPosition,
581 refany::OptionRefAny,
582 resources::RendererResources,
583 styled_dom::{NodeHierarchyItemId, StyledDom},
584 window::{MonitorVec, RawWindowHandle},
585 };
586 use azul_css::{
587 props::basic::{length::SizeMetric, pixel::PixelValue},
588 system::SystemStyle,
589 };
590 use rust_fontconfig::FcFontCache;
591
592 use super::*;
593 #[cfg(feature = "icu")]
594 use crate::icu::IcuLocalizerHandle;
595 use crate::{
596 callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
597 solver3::{display_list::DisplayList, layout_tree::LayoutTree},
598 window::{DomLayoutResult, LayoutWindow},
599 window_state::FullWindowState,
600 };
601
602 const _LEFT_AT_MAX: ConnFill = conn_left_fill(usize::MAX, usize::MAX);
611 const _LEFT_MAX_AT_ZERO: ConnFill = conn_left_fill(usize::MAX, 0);
612 const _LEFT_ZERO_AT_MAX: ConnFill = conn_left_fill(0, usize::MAX);
613 const _RIGHT_AT_MAX: ConnFill = conn_right_fill(usize::MAX, usize::MAX, usize::MAX);
614 const _RIGHT_MAX_AT_ZERO: ConnFill = conn_right_fill(usize::MAX, 0, 0);
615 const _RIGHT_ZERO_AT_MAX: ConnFill = conn_right_fill(0, usize::MAX, usize::MAX);
616
617 const NODES_PER_STEP: usize = 6;
630
631 fn cell_node(i: usize) -> usize {
632 1 + NODES_PER_STEP * i
633 }
634 fn row_node(i: usize) -> usize {
635 2 + NODES_PER_STEP * i
636 }
637 fn conn_left_node(i: usize) -> usize {
638 3 + NODES_PER_STEP * i
639 }
640 fn circle_node(i: usize) -> usize {
641 4 + NODES_PER_STEP * i
642 }
643 fn conn_right_node(i: usize) -> usize {
644 5 + NODES_PER_STEP * i
645 }
646 fn label_node(i: usize) -> usize {
647 6 + NODES_PER_STEP * i
648 }
649
650 fn labels(v: &[&str]) -> StringVec {
655 StringVec::from_vec(v.iter().map(|s| AzString::from(*s)).collect::<Vec<_>>())
656 }
657
658 fn n_labels(n: usize) -> StringVec {
660 StringVec::from_vec((0..n).map(|i| AzString::from(format!("s{i}"))).collect::<Vec<_>>())
661 }
662
663 fn stepper(v: &[&str]) -> Stepper {
664 Stepper::create(labels(v))
665 }
666
667 fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
669 v.as_ref().iter().map(|p| p.property.clone()).collect()
670 }
671
672 fn property_kinds(
674 v: &CssPropertyWithConditionsVec,
675 ) -> Vec<core::mem::Discriminant<CssProperty>> {
676 v.as_ref().iter().map(|p| core::mem::discriminant(&p.property)).collect()
677 }
678
679 fn assert_unconditional_and_unique(v: &CssPropertyWithConditionsVec, ctx: &str) {
684 let mut seen = HashSet::new();
685 for p in v.as_ref() {
686 assert!(
687 p.apply_if.as_ref().is_empty(),
688 "{ctx}: {:?} is conditional, not an unconditional declaration",
689 p.property
690 );
691 assert!(
692 seen.insert(core::mem::discriminant(&p.property)),
693 "{ctx}: {:?} is declared twice",
694 p.property
695 );
696 }
697 assert_eq!(seen.len(), v.as_ref().len(), "{ctx}: declaration count / kind count disagree");
698 }
699
700 fn only_color(bg: &StyleBackgroundContentVec) -> ColorU {
703 assert_eq!(bg.as_ref().len(), 1, "a stepper fill must be a single background layer");
704 match &bg.as_ref()[0] {
705 StyleBackgroundContent::Color(c) => *c,
706 other => panic!("stepper background is not a flat colour: {other:?}"),
707 }
708 }
709
710 fn fill_color(f: ConnFill) -> ColorU {
713 only_color(&f.bg())
714 }
715
716 fn background_color(props: &[CssProperty]) -> Option<ColorU> {
717 let found: Vec<&StyleBackgroundContentVec> = props
718 .iter()
719 .filter_map(|p| match p {
720 CssProperty::BackgroundContent(v) => v.get_property(),
721 _ => None,
722 })
723 .collect();
724 assert!(found.len() <= 1, "a stepper node must declare at most one background");
725 found.first().map(|bg| only_color(bg))
726 }
727
728 fn text_color(props: &[CssProperty]) -> Option<ColorU> {
729 let found: Vec<ColorU> = props
730 .iter()
731 .filter_map(|p| match p {
732 CssProperty::TextColor(v) => v.get_property().map(|c| c.inner),
733 _ => None,
734 })
735 .collect();
736 assert!(found.len() <= 1, "a stepper node must declare at most one text colour");
737 found.first().copied()
738 }
739
740 fn width_value(props: &[CssProperty]) -> Option<PixelValue> {
744 props.iter().find_map(|p| match p {
745 CssProperty::Width(v) => match v.get_property() {
746 Some(LayoutWidth::Px(pv)) => Some(*pv),
747 _ => None,
748 },
749 _ => None,
750 })
751 }
752
753 fn height_value(props: &[CssProperty]) -> Option<PixelValue> {
754 props.iter().find_map(|p| match p {
755 CssProperty::Height(v) => match v.get_property() {
756 Some(LayoutHeight::Px(pv)) => Some(*pv),
757 _ => None,
758 },
759 _ => None,
760 })
761 }
762
763 fn min_width_value(props: &[CssProperty]) -> Option<PixelValue> {
764 props.iter().find_map(|p| match p {
765 CssProperty::MinWidth(v) => v.get_property().map(|x| x.inner),
766 _ => None,
767 })
768 }
769
770 fn font_size_value(props: &[CssProperty]) -> Option<PixelValue> {
771 props.iter().find_map(|p| match p {
772 CssProperty::FontSize(v) => v.get_property().map(|x| x.inner),
773 _ => None,
774 })
775 }
776
777 fn padding_top_value(props: &[CssProperty]) -> Option<PixelValue> {
778 props.iter().find_map(|p| match p {
779 CssProperty::PaddingTop(v) => v.get_property().map(|x| x.inner),
780 _ => None,
781 })
782 }
783
784 fn flex_grow_value(props: &[CssProperty]) -> Option<f32> {
785 props.iter().find_map(|p| match p {
786 CssProperty::FlexGrow(v) => v.get_property().map(|f| f.inner.get()),
787 _ => None,
788 })
789 }
790
791 fn radii(props: &[CssProperty]) -> (Option<PixelValue>, Option<PixelValue>, Option<PixelValue>, Option<PixelValue>) {
793 let find = |f: &dyn Fn(&CssProperty) -> Option<PixelValue>| props.iter().find_map(f);
794 (
795 find(&|p| match p {
796 CssProperty::BorderTopLeftRadius(v) => v.get_property().map(|r| r.inner),
797 _ => None,
798 }),
799 find(&|p| match p {
800 CssProperty::BorderTopRightRadius(v) => v.get_property().map(|r| r.inner),
801 _ => None,
802 }),
803 find(&|p| match p {
804 CssProperty::BorderBottomLeftRadius(v) => v.get_property().map(|r| r.inner),
805 _ => None,
806 }),
807 find(&|p| match p {
808 CssProperty::BorderBottomRightRadius(v) => v.get_property().map(|r| r.inner),
809 _ => None,
810 }),
811 )
812 }
813
814 fn px(pv: PixelValue) -> f32 {
818 assert_eq!(pv.metric, SizeMetric::Px, "stepper geometry must be absolute px, got {:?}", pv.metric);
819 pv.number.get()
820 }
821
822 fn luma(c: ColorU) -> f32 {
826 0.2126 * f32::from(c.r) + 0.7152 * f32::from(c.g) + 0.0722 * f32::from(c.b)
827 }
828
829 fn text_of(node: &Dom) -> Option<&str> {
831 match node.root.get_node_type() {
832 NodeType::Text(s) => Some(s.as_ref().as_str()),
833 _ => None,
834 }
835 }
836
837 fn inline_properties(node: &Dom) -> Vec<CssProperty> {
839 node.root.style.iter_inline_properties().map(|(p, _)| p.clone()).collect()
840 }
841
842 fn recursive_descendants(node: &Dom) -> usize {
846 node.children.as_ref().iter().map(|c| 1 + recursive_descendants(c)).sum()
847 }
848
849 fn step_cell(dom: &Dom, i: usize) -> &Dom {
850 &dom.children.as_ref()[i]
851 }
852 fn row_of(cell: &Dom) -> &Dom {
853 &cell.children.as_ref()[0]
854 }
855 fn label_of(cell: &Dom) -> &Dom {
856 &cell.children.as_ref()[1]
857 }
858 fn conn_left_of(row: &Dom) -> &Dom {
859 &row.children.as_ref()[0]
860 }
861 fn circle_of(row: &Dom) -> &Dom {
862 &row.children.as_ref()[1]
863 }
864 fn conn_right_of(row: &Dom) -> &Dom {
865 &row.children.as_ref()[2]
866 }
867
868 fn boundary_indices() -> Vec<usize> {
872 vec![
873 0,
874 1,
875 2,
876 usize::MAX / 2,
877 usize::MAX / 2 + 1,
878 usize::MAX - 1,
879 usize::MAX,
880 (-1i64) as usize,
881 i64::MIN as usize,
882 u32::MAX as usize,
883 ]
884 }
885
886 fn adversarial_strings() -> Vec<String> {
891 let mut v: Vec<String> = [
892 "",
893 "Details",
894 " ",
895 "e\u{0301}", "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}", "\u{5E9}\u{5DC}\u{5D5}\u{5DD}", "\0", "a\0b", "\u{FFFD}\u{202E}\u{200B}", "line\nbreak\ttab", "-9223372036854775808", ]
904 .iter()
905 .map(|s| (*s).to_string())
906 .collect();
907 v.push("x".repeat(100_000));
908 v
909 }
910
911 fn cb(f: StepperOnStepChangeCallbackType) -> StepperOnStepChangeCallback {
913 f.into()
914 }
915
916 struct StepLog {
918 seen: Vec<StepperState>,
919 }
920
921 extern "C" fn record_step(mut data: RefAny, _: CallbackInfo, state: StepperState) -> Update {
922 if let Some(mut log) = data.downcast_mut::<StepLog>() {
923 log.seen.push(state);
924 }
925 Update::RefreshDom
926 }
927
928 extern "C" fn step_do_nothing(_: RefAny, _: CallbackInfo, _: StepperState) -> Update {
929 Update::DoNothing
930 }
931
932 extern "C" fn step_refresh_all(_: RefAny, _: CallbackInfo, state: StepperState) -> Update {
933 let _ = state.current_step;
937 Update::RefreshDomAllWindows
938 }
939
940 struct ReentrantProbe {
943 state: RefAny,
945 saw_step: Option<usize>,
948 calls: usize,
949 }
950
951 extern "C" fn probe_state_reentrantly(
952 mut data: RefAny,
953 _: CallbackInfo,
954 _: StepperState,
955 ) -> Update {
956 if let Some(mut probe) = data.downcast_mut::<ReentrantProbe>() {
957 probe.calls += 1;
958 let mut state = probe.state.clone();
959 probe.saw_step = state.downcast_ref::<StepperStateWrapper>().map(|w| w.inner.current_step);
960 }
961 Update::DoNothing
962 }
963
964 fn logged(data: &mut RefAny) -> Vec<StepperState> {
965 data.downcast_ref::<StepLog>().expect("payload must still be a StepLog").seen.clone()
966 }
967
968 fn current_step_of(data: &mut RefAny) -> usize {
969 data.downcast_ref::<StepperStateWrapper>()
970 .expect("payload must still be a StepperStateWrapper")
971 .inner
972 .current_step
973 }
974
975 fn step_state(dom: &Dom, i: usize) -> RefAny {
977 dom.children.as_ref()[i]
978 .root
979 .get_callbacks()
980 .as_ref()
981 .first()
982 .expect("every step cell must carry the click callback")
983 .refany
984 .clone()
985 }
986
987 fn node(idx: usize) -> DomNodeId {
988 DomNodeId {
989 dom: DomId::ROOT_ID,
990 node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
991 }
992 }
993
994 fn node_none() -> DomNodeId {
998 DomNodeId {
999 dom: DomId::ROOT_ID,
1000 node: NodeHierarchyItemId::NONE,
1001 }
1002 }
1003
1004 fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
1007 DomLayoutResult {
1008 styled_dom,
1009 layout_tree: LayoutTree {
1010 nodes: Vec::new(),
1011 warm: Vec::new(),
1012 cold: Vec::new(),
1013 root: 0,
1014 dom_to_layout: BTreeMap::new(),
1015 children_arena: Vec::new(),
1016 children_offsets: Vec::new(),
1017 subtree_needs_intrinsic: Vec::new(),
1018 },
1019 calculated_positions: Vec::new(),
1020 viewport: LogicalRect::zero(),
1021 display_list: DisplayList::default(),
1022 scroll_ids: HashMap::new(),
1023 scroll_id_to_node_id: HashMap::new(),
1024 }
1025 }
1026
1027 fn flatten(s: Stepper) -> (StyledDom, RefAny) {
1033 let dom = s.dom();
1034 let state = step_state(&dom, 0);
1035 (StyledDom::create_from_dom(dom), state)
1036 }
1037
1038 fn run_click(
1042 styled: Option<StyledDom>,
1043 hit: DomNodeId,
1044 data: RefAny,
1045 ) -> (Update, Vec<CallbackChange>) {
1046 let mut layout_window =
1047 LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
1048 if let Some(sd) = styled {
1049 layout_window.layout_results.insert(DomId::ROOT_ID, layout_result(sd));
1050 }
1051
1052 let renderer_resources = RendererResources::default();
1053 let previous_window_state: Option<FullWindowState> = None;
1054 let current_window_state = FullWindowState::default();
1055 let gl_context = OptionGlContextPtr::None;
1056 let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
1057 BTreeMap::new();
1058 let window_handle = RawWindowHandle::Unsupported;
1059 let system_callbacks = ExternalSystemCallbacks::rust_internal();
1060
1061 let ref_data = CallbackInfoRefData {
1062 layout_window: &layout_window,
1063 renderer_resources: &renderer_resources,
1064 previous_window_state: &previous_window_state,
1065 current_window_state: ¤t_window_state,
1066 gl_context: &gl_context,
1067 current_scroll_manager: &scroll_states,
1068 current_window_handle: &window_handle,
1069 system_callbacks: &system_callbacks,
1070 system_style: Arc::new(SystemStyle::default()),
1071 monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
1072 #[cfg(feature = "icu")]
1073 icu_localizer: IcuLocalizerHandle::default(),
1074 ctx: OptionRefAny::None,
1075 };
1076
1077 let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
1078
1079 let info = CallbackInfo::new(
1080 &ref_data,
1081 &changes,
1082 hit,
1083 OptionLogicalPosition::None,
1084 OptionLogicalPosition::None,
1085 );
1086
1087 let update = on_step_click(data, info);
1088 let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
1089 (update, recorded)
1090 }
1091
1092 fn restyle_writes(changes: &[CallbackChange]) -> Vec<(usize, &'static str, ColorU)> {
1096 let mut out = Vec::new();
1097 for change in changes {
1098 let CallbackChange::ChangeNodeCssProperties { node_id, properties, .. } = change else {
1099 panic!("the restyle must only emit ChangeNodeCssProperties, got {change:?}");
1100 };
1101 for p in properties.as_ref() {
1102 match p {
1103 CssProperty::BackgroundContent(v) => {
1104 let layers =
1105 v.get_property().expect("the restyle must write an exact background");
1106 out.push((node_id.index(), "bg", only_color(layers)));
1107 }
1108 CssProperty::TextColor(v) => {
1109 let c =
1110 v.get_property().expect("the restyle must write an exact text colour");
1111 out.push((node_id.index(), "text", c.inner));
1112 }
1113 other => panic!("unexpected restyle property: {other:?}"),
1114 }
1115 }
1116 }
1117 out
1118 }
1119
1120 fn expected_restyle(n: usize, clicked: usize) -> Vec<(usize, &'static str, ColorU)> {
1124 let last = n.saturating_sub(1);
1125 let mut out = Vec::with_capacity(5 * n);
1126 for i in 0..n {
1127 let reached = i <= clicked;
1128 out.push((
1129 circle_node(i),
1130 "bg",
1131 if reached { ACCENT_COLOR } else { MUTED_CIRCLE_COLOR },
1132 ));
1133 out.push((circle_node(i), "text", if reached { WHITE } else { MUTED_TEXT_COLOR }));
1134 out.push((conn_left_node(i), "bg", fill_color(conn_left_fill(i, clicked))));
1135 out.push((conn_right_node(i), "bg", fill_color(conn_right_fill(i, last, clicked))));
1136 out.push((
1137 label_node(i),
1138 "text",
1139 if reached { DARK_TEXT_COLOR } else { MUTED_TEXT_COLOR },
1140 ));
1141 }
1142 out
1143 }
1144
1145 #[test]
1150 fn conn_fill_bg_maps_each_variant_to_its_own_flat_colour() {
1151 assert_eq!(fill_color(ConnFill::Accent), ACCENT_COLOR);
1152 assert_eq!(fill_color(ConnFill::Muted), CONNECTOR_MUTED_COLOR);
1153 assert_eq!(fill_color(ConnFill::Hidden), TRANSPARENT_COLOR);
1154 }
1155
1156 #[test]
1157 fn conn_fill_bg_colours_are_pairwise_distinct_and_correctly_opaque() {
1158 let accent = fill_color(ConnFill::Accent);
1159 let muted = fill_color(ConnFill::Muted);
1160 let hidden = fill_color(ConnFill::Hidden);
1161
1162 assert_ne!(accent, muted, "reached and unreached connectors must be distinguishable");
1163 assert_ne!(accent, hidden);
1164 assert_ne!(muted, hidden);
1165
1166 assert_eq!(accent.a, 255, "a translucent accent track lets the page bleed through");
1167 assert_eq!(muted.a, 255, "a translucent muted track lets the page bleed through");
1168 assert_eq!(hidden.a, 0, "the row-end connector must be fully transparent, not merely pale");
1169 }
1170
1171 #[test]
1172 fn conn_fill_bg_is_pure_and_single_layered() {
1173 for f in [ConnFill::Accent, ConnFill::Muted, ConnFill::Hidden] {
1176 let a = f.bg();
1177 let b = f.bg();
1178 assert_eq!(a, b, "ConnFill::bg is not pure");
1179 assert_eq!(a.as_ref().len(), 1, "a connector fill is exactly one layer");
1180 }
1181 }
1182
1183 #[test]
1188 fn row_style_is_a_full_width_vertically_centred_flex_row() {
1189 let style = row_style();
1190 let props = properties(&style);
1191 assert_eq!(props.len(), 4, "the indicator row declares exactly four properties");
1192
1193 assert!(
1194 props.iter().any(|p| matches!(
1195 p, CssProperty::Display(d) if d.get_property() == Some(&LayoutDisplay::Flex))),
1196 "the indicator row must be a flex box"
1197 );
1198 assert!(
1199 props.iter().any(|p| matches!(
1200 p, CssProperty::FlexDirection(d)
1201 if d.get_property() == Some(&LayoutFlexDirection::Row))),
1202 "connectors sit left/right of the circle, so the row is horizontal"
1203 );
1204 assert!(
1205 props.iter().any(|p| matches!(
1206 p, CssProperty::AlignItems(a)
1207 if a.get_property() == Some(&LayoutAlignItems::Center))),
1208 "the 2px connectors must be centred on the 28px circle"
1209 );
1210 }
1211
1212 #[test]
1213 fn row_style_width_is_a_full_percentage_not_a_pixel_length() {
1214 let style = row_style();
1217 let w = width_value(&properties(&style)).expect("the indicator row must declare a width");
1218 assert_eq!(w.metric, SizeMetric::Percent, "the row width must be relative to the cell");
1219 assert!(
1220 (w.number.get() - 100.0).abs() < f32::EPSILON,
1221 "the row must span the whole cell, got {}",
1222 w.number.get()
1223 );
1224 }
1225
1226 #[test]
1227 fn row_style_is_unconditional_unique_and_pure() {
1228 assert_unconditional_and_unique(&row_style(), "row_style");
1229 assert_eq!(properties(&row_style()), properties(&row_style()), "row_style is not pure");
1230 }
1231
1232 #[test]
1237 fn circle_style_declares_the_same_property_set_for_both_states() {
1238 let reached = circle_style(true);
1241 let unreached = circle_style(false);
1242
1243 assert_eq!(reached.as_ref().len(), 18, "the circle declares eighteen properties");
1244 assert_eq!(unreached.as_ref().len(), 18);
1245 assert_eq!(
1246 property_kinds(&reached),
1247 property_kinds(&unreached),
1248 "the two circle states must declare the same properties in the same order"
1249 );
1250 }
1251
1252 #[test]
1253 fn circle_style_colours_are_the_only_reached_dependent_declarations() {
1254 let reached = properties(&circle_style(true));
1255 let unreached = properties(&circle_style(false));
1256
1257 assert_eq!(background_color(&reached), Some(ACCENT_COLOR));
1258 assert_eq!(text_color(&reached), Some(WHITE));
1259 assert_eq!(background_color(&unreached), Some(MUTED_CIRCLE_COLOR));
1260 assert_eq!(text_color(&unreached), Some(MUTED_TEXT_COLOR));
1261
1262 let strip = |v: &[CssProperty]| -> Vec<CssProperty> {
1264 v.iter()
1265 .filter(|p| {
1266 !matches!(
1267 p,
1268 CssProperty::BackgroundContent(_) | CssProperty::TextColor(_)
1269 )
1270 })
1271 .cloned()
1272 .collect()
1273 };
1274 assert_eq!(
1275 strip(&reached),
1276 strip(&unreached),
1277 "reached-ness leaked into a non-colour property"
1278 );
1279 }
1280
1281 #[test]
1282 fn circle_style_geometry_is_a_fixed_pixel_circle() {
1283 for reached in [false, true] {
1284 let props = properties(&circle_style(reached));
1285 let ctx = format!("reached={reached}");
1286
1287 let w = px(width_value(&props).expect("width"));
1288 let h = px(height_value(&props).expect("height"));
1289 let mw = px(min_width_value(&props).expect("min-width"));
1290
1291 assert!((w - CIRCLE_SIZE as f32).abs() < f32::EPSILON, "{ctx}: width");
1292 assert!((h - CIRCLE_SIZE as f32).abs() < f32::EPSILON, "{ctx}: height");
1293 assert!(
1294 (mw - CIRCLE_SIZE as f32).abs() < f32::EPSILON,
1295 "{ctx}: without a min-width the circle would squash in a tight flex row"
1296 );
1297 assert!((w - h).abs() < f32::EPSILON, "{ctx}: a circle must be square");
1298
1299 let (tl, tr, bl, br) = radii(&props);
1300 let r = CIRCLE_RADIUS as f32;
1301 for (corner, v) in [("top-left", tl), ("top-right", tr), ("bottom-left", bl), ("bottom-right", br)] {
1302 let v = px(v.unwrap_or_else(|| panic!("{ctx}: missing {corner} radius")));
1303 assert!((v - r).abs() < f32::EPSILON, "{ctx}: {corner} radius is {v}, want {r}");
1304 }
1305
1306 assert_eq!(
1307 CIRCLE_RADIUS * 2,
1308 CIRCLE_SIZE,
1309 "a radius that is not half the box renders a rounded square, not a circle"
1310 );
1311 assert_eq!(
1312 flex_grow_value(&props),
1313 Some(0.0),
1314 "{ctx}: the circle must keep its fixed size, not stretch"
1315 );
1316 assert!(
1317 (px(font_size_value(&props).expect("font size")) - 13.0).abs() < f32::EPSILON,
1318 "{ctx}: font size"
1319 );
1320 }
1321 }
1322
1323 #[test]
1324 fn circle_style_centres_its_number_and_declares_the_interaction_affordances() {
1325 for reached in [false, true] {
1326 let props = properties(&circle_style(reached));
1327 let ctx = format!("reached={reached}");
1328
1329 assert!(
1330 props.iter().any(|p| matches!(
1331 p, CssProperty::JustifyContent(j)
1332 if j.get_property() == Some(&LayoutJustifyContent::Center))),
1333 "{ctx}: the step number must be horizontally centred in the circle"
1334 );
1335 assert!(
1336 props.iter().any(|p| matches!(
1337 p, CssProperty::AlignItems(a)
1338 if a.get_property() == Some(&LayoutAlignItems::Center))),
1339 "{ctx}: the step number must be vertically centred in the circle"
1340 );
1341 assert!(
1342 props.iter().any(|p| matches!(
1343 p, CssProperty::TextAlign(t)
1344 if t.get_property() == Some(&StyleTextAlign::Center))),
1345 "{ctx}: text-align"
1346 );
1347 assert!(
1348 props.iter().any(|p| matches!(
1349 p, CssProperty::Cursor(c) if c.get_property() == Some(&StyleCursor::Pointer))),
1350 "{ctx}: a clickable step must show the pointer cursor"
1351 );
1352 assert!(
1353 props.iter().any(|p| matches!(
1354 p, CssProperty::UserSelect(u)
1355 if u.get_property() == Some(&StyleUserSelect::None))),
1356 "{ctx}: click-dragging a step must not select its number"
1357 );
1358 }
1359 }
1360
1361 #[test]
1362 fn circle_style_keeps_the_number_readable_and_opaque() {
1363 for reached in [false, true] {
1364 let props = properties(&circle_style(reached));
1365 let bg = background_color(&props).expect("background");
1366 let fg = text_color(&props).expect("text colour");
1367
1368 assert_eq!(bg.a, 255, "reached={reached}: a translucent circle lets the page bleed through");
1369 assert_eq!(fg.a, 255, "reached={reached}: translucent number");
1370 assert_ne!(bg, fg, "reached={reached}: an invisible number is not a step indicator");
1371 assert!(
1372 (luma(bg) - luma(fg)).abs() >= 60.0,
1373 "reached={reached}: brightness gap {:.1} is too low to read",
1374 (luma(bg) - luma(fg)).abs()
1375 );
1376 }
1377
1378 assert_ne!(
1381 background_color(&properties(&circle_style(true))),
1382 background_color(&properties(&circle_style(false)))
1383 );
1384 }
1385
1386 #[test]
1387 fn circle_style_is_unconditional_unique_and_pure() {
1388 for reached in [false, true] {
1389 let ctx = format!("circle_style({reached})");
1390 assert_unconditional_and_unique(&circle_style(reached), &ctx);
1391 assert_eq!(
1392 properties(&circle_style(reached)),
1393 properties(&circle_style(reached)),
1394 "{ctx} is not pure"
1395 );
1396 }
1397 }
1398
1399 #[test]
1404 fn connector_style_declares_only_grow_height_and_fill() {
1405 for f in [ConnFill::Accent, ConnFill::Muted, ConnFill::Hidden] {
1406 let style = connector_style(f);
1407 let props = properties(&style);
1408 assert_eq!(props.len(), 3, "a connector half-line declares exactly three properties");
1409
1410 assert_eq!(
1411 flex_grow_value(&props),
1412 Some(1.0),
1413 "a connector must absorb all leftover width, otherwise the track is invisible"
1414 );
1415 let h = px(height_value(&props).expect("a connector must declare a height"));
1416 assert!(
1417 (h - CONNECTOR_HEIGHT as f32).abs() < f32::EPSILON,
1418 "connector height {h}, want {CONNECTOR_HEIGHT}"
1419 );
1420 assert_eq!(background_color(&props), Some(fill_color(f)));
1421 }
1422 }
1423
1424 #[test]
1425 fn connector_style_geometry_is_independent_of_the_fill() {
1426 let strip = |f: ConnFill| -> Vec<CssProperty> {
1429 properties(&connector_style(f))
1430 .into_iter()
1431 .filter(|p| !matches!(p, CssProperty::BackgroundContent(_)))
1432 .collect()
1433 };
1434 let accent = strip(ConnFill::Accent);
1435 assert_eq!(accent, strip(ConnFill::Muted), "muted connectors changed shape");
1436 assert_eq!(accent, strip(ConnFill::Hidden), "hidden connectors changed shape");
1437 assert_eq!(
1438 property_kinds(&connector_style(ConnFill::Accent)),
1439 property_kinds(&connector_style(ConnFill::Hidden)),
1440 "a hidden connector must declare the same properties, only a different colour"
1441 );
1442 }
1443
1444 #[test]
1445 fn connector_style_is_unconditional_unique_and_pure() {
1446 for (name, f) in
1447 [("accent", ConnFill::Accent), ("muted", ConnFill::Muted), ("hidden", ConnFill::Hidden)]
1448 {
1449 let ctx = format!("connector_style({name})");
1450 assert_unconditional_and_unique(&connector_style(f), &ctx);
1451 assert_eq!(
1452 properties(&connector_style(f)),
1453 properties(&connector_style(f)),
1454 "{ctx} is not pure"
1455 );
1456 }
1457 }
1458
1459 #[test]
1464 fn label_style_declares_the_same_six_properties_for_both_states() {
1465 let reached = label_style(true);
1466 let unreached = label_style(false);
1467 assert_eq!(reached.as_ref().len(), 6, "a step label declares exactly six properties");
1468 assert_eq!(unreached.as_ref().len(), 6);
1469 assert_eq!(property_kinds(&reached), property_kinds(&unreached));
1470 }
1471
1472 #[test]
1473 fn label_style_colour_is_the_only_reached_dependent_declaration() {
1474 let reached = properties(&label_style(true));
1475 let unreached = properties(&label_style(false));
1476
1477 assert_eq!(text_color(&reached), Some(DARK_TEXT_COLOR));
1478 assert_eq!(text_color(&unreached), Some(MUTED_TEXT_COLOR));
1479 assert_ne!(
1480 text_color(&reached),
1481 text_color(&unreached),
1482 "reached and upcoming labels must be distinguishable"
1483 );
1484 assert_eq!(background_color(&reached), None, "a step label paints no background");
1485
1486 let strip = |v: &[CssProperty]| -> Vec<CssProperty> {
1487 v.iter().filter(|p| !matches!(p, CssProperty::TextColor(_))).cloned().collect()
1488 };
1489 assert_eq!(strip(&reached), strip(&unreached), "reached-ness leaked past the colour");
1490 }
1491
1492 #[test]
1493 fn label_style_geometry_and_affordances() {
1494 for reached in [false, true] {
1495 let props = properties(&label_style(reached));
1496 let ctx = format!("reached={reached}");
1497
1498 assert!(
1499 (px(font_size_value(&props).expect("font size")) - 12.0).abs() < f32::EPSILON,
1500 "{ctx}: labels are one point smaller than the circle number"
1501 );
1502 assert!(
1503 (px(padding_top_value(&props).expect("padding-top")) - 6.0).abs() < f32::EPSILON,
1504 "{ctx}: the label must clear the indicator row"
1505 );
1506 assert!(
1507 props.iter().any(|p| matches!(
1508 p, CssProperty::TextAlign(t)
1509 if t.get_property() == Some(&StyleTextAlign::Center))),
1510 "{ctx}: labels are centred under their circle"
1511 );
1512 assert!(
1513 props.iter().any(|p| matches!(
1514 p, CssProperty::Cursor(c) if c.get_property() == Some(&StyleCursor::Pointer))),
1515 "{ctx}: the label is part of the clickable cell"
1516 );
1517 assert!(
1518 props.iter().any(|p| matches!(
1519 p, CssProperty::UserSelect(u)
1520 if u.get_property() == Some(&StyleUserSelect::None))),
1521 "{ctx}: click-dragging must not select the caption"
1522 );
1523 }
1524 }
1525
1526 #[test]
1527 fn label_style_stays_readable_on_a_white_page() {
1528 for reached in [false, true] {
1529 let fg = text_color(&properties(&label_style(reached))).expect("text colour");
1530 assert_eq!(fg.a, 255, "reached={reached}: translucent label text");
1531 assert!(
1532 (luma(WHITE) - luma(fg)) >= 60.0,
1533 "reached={reached}: label brightness gap against white is only {:.1}",
1534 luma(WHITE) - luma(fg)
1535 );
1536 }
1537 }
1538
1539 #[test]
1540 fn label_style_is_unconditional_unique_and_pure() {
1541 for reached in [false, true] {
1542 let ctx = format!("label_style({reached})");
1543 assert_unconditional_and_unique(&label_style(reached), &ctx);
1544 assert_eq!(
1545 properties(&label_style(reached)),
1546 properties(&label_style(reached)),
1547 "{ctx} is not pure"
1548 );
1549 }
1550 }
1551
1552 #[test]
1557 fn conn_left_fill_hides_the_leading_edge_whatever_the_current_step() {
1558 for current in [0usize, 1, 2, usize::MAX / 2, usize::MAX - 1, usize::MAX] {
1561 assert_eq!(
1562 fill_color(conn_left_fill(0, current)),
1563 TRANSPARENT_COLOR,
1564 "current={current}: the row's leading edge must stay hidden"
1565 );
1566 }
1567 }
1568
1569 #[test]
1570 fn conn_left_fill_is_accent_up_to_and_including_the_current_step() {
1571 for current in 0..6usize {
1572 for i in 1..8usize {
1573 let want = if i <= current { ACCENT_COLOR } else { CONNECTOR_MUTED_COLOR };
1574 assert_eq!(
1575 fill_color(conn_left_fill(i, current)),
1576 want,
1577 "i={i}, current={current}: wrong left-connector fill"
1578 );
1579 }
1580 }
1581 }
1582
1583 #[test]
1584 fn conn_left_fill_at_the_integer_extremes_does_not_panic() {
1585 let cases: [(usize, usize, ColorU); 7] = [
1587 (usize::MAX, usize::MAX, ACCENT_COLOR),
1588 (usize::MAX, usize::MAX - 1, CONNECTOR_MUTED_COLOR),
1589 (usize::MAX - 1, usize::MAX, ACCENT_COLOR),
1590 (usize::MAX, 0, CONNECTOR_MUTED_COLOR),
1591 (1, usize::MAX, ACCENT_COLOR),
1592 ((-1i64) as usize, (-1i64) as usize, ACCENT_COLOR),
1593 (i64::MIN as usize, usize::MAX, ACCENT_COLOR),
1594 ];
1595 for (i, current, want) in cases {
1596 assert_eq!(fill_color(conn_left_fill(i, current)), want, "i={i}, current={current}");
1597 }
1598 }
1599
1600 #[test]
1601 fn conn_left_fill_is_monotone_in_the_current_step() {
1602 for i in 1..6usize {
1605 let mut seen_accent = false;
1606 for current in 0..12usize {
1607 let accent = fill_color(conn_left_fill(i, current)) == ACCENT_COLOR;
1608 if seen_accent {
1609 assert!(accent, "i={i}: the left connector un-filled at current={current}");
1610 }
1611 seen_accent |= accent;
1612 }
1613 assert!(seen_accent, "i={i}: the left connector never fills");
1614 }
1615 }
1616
1617 #[test]
1618 fn conn_left_fill_only_ever_returns_one_of_the_three_known_colours() {
1619 for i in 0..8usize {
1620 for current in 0..8usize {
1621 let c = fill_color(conn_left_fill(i, current));
1622 assert!(
1623 c == ACCENT_COLOR || c == CONNECTOR_MUTED_COLOR || c == TRANSPARENT_COLOR,
1624 "i={i}, current={current}: unexpected colour {c:?}"
1625 );
1626 }
1627 }
1628 }
1629
1630 #[test]
1635 fn conn_right_fill_hides_the_trailing_edge_of_the_last_step() {
1636 for last in [0usize, 1, 4, usize::MAX] {
1637 for current in [0usize, 1, usize::MAX] {
1638 assert_eq!(
1639 fill_color(conn_right_fill(last, last, current)),
1640 TRANSPARENT_COLOR,
1641 "last={last}, current={current}: the row's trailing edge must stay hidden"
1642 );
1643 }
1644 }
1645 }
1646
1647 #[test]
1648 fn conn_right_fill_is_accent_strictly_before_the_current_step() {
1649 let last = 7usize;
1650 for current in 0..8usize {
1651 for i in 0..last {
1652 let want = if i < current { ACCENT_COLOR } else { CONNECTOR_MUTED_COLOR };
1653 assert_eq!(
1654 fill_color(conn_right_fill(i, last, current)),
1655 want,
1656 "i={i}, last={last}, current={current}: wrong right-connector fill"
1657 );
1658 }
1659 }
1660 }
1661
1662 #[test]
1663 fn conn_right_fill_lets_last_win_over_an_accent_current() {
1664 for current in [3usize, 4, 100, usize::MAX] {
1667 assert_eq!(
1668 fill_color(conn_right_fill(3, 3, current)),
1669 TRANSPARENT_COLOR,
1670 "current={current}: the last step grew a trailing accent stub"
1671 );
1672 }
1673 }
1674
1675 #[test]
1676 fn conn_right_fill_at_the_integer_extremes_does_not_panic() {
1677 let cases: [(usize, usize, usize, ColorU); 7] = [
1678 (usize::MAX, usize::MAX, usize::MAX, TRANSPARENT_COLOR),
1679 (usize::MAX - 1, usize::MAX, usize::MAX, ACCENT_COLOR),
1680 (usize::MAX - 1, usize::MAX, 0, CONNECTOR_MUTED_COLOR),
1681 (0, usize::MAX, usize::MAX, ACCENT_COLOR),
1682 (0, 0, usize::MAX, TRANSPARENT_COLOR),
1683 ((-1i64) as usize, (-1i64) as usize, 0, TRANSPARENT_COLOR),
1684 (i64::MIN as usize, usize::MAX, usize::MAX, ACCENT_COLOR),
1685 ];
1686 for (i, last, current, want) in cases {
1687 assert_eq!(
1688 fill_color(conn_right_fill(i, last, current)),
1689 want,
1690 "i={i}, last={last}, current={current}"
1691 );
1692 }
1693 }
1694
1695 #[test]
1696 fn the_two_halves_of_every_gap_agree() {
1697 for n in 1..8usize {
1701 let last = n - 1;
1702 for current in 0..(n + 2) {
1703 for i in 0..last {
1704 assert_eq!(
1705 fill_color(conn_right_fill(i, last, current)),
1706 fill_color(conn_left_fill(i + 1, current)),
1707 "n={n}, current={current}: the two halves of gap {i}→{} disagree",
1708 i + 1
1709 );
1710 }
1711 }
1712 }
1713 }
1714
1715 #[test]
1720 fn create_preserves_labels_verbatim() {
1721 for case in [
1722 vec![],
1723 vec!["only"],
1724 vec!["Account", "Address"],
1725 vec!["Account", "Address", "Payment", "Review"],
1726 vec!["dup", "dup", "dup"],
1727 ] {
1728 let s = Stepper::create(labels(&case));
1729 let got: Vec<&str> = s.labels.as_ref().iter().map(AzString::as_str).collect();
1730 assert_eq!(got, case, "create must not reorder/drop/dedupe/rewrite labels");
1731 }
1732 }
1733
1734 #[test]
1735 fn create_preserves_adversarial_labels_byte_for_byte() {
1736 for s in adversarial_strings() {
1737 let w = Stepper::create(labels(&[s.as_str()]));
1738 assert_eq!(
1739 w.labels.as_ref()[0].as_str(),
1740 s.as_str(),
1741 "the caption changed on its way into the widget"
1742 );
1743 assert_eq!(
1744 w.labels.as_ref()[0].as_ref().len(),
1745 s.len(),
1746 "byte length changed (NUL truncation?)"
1747 );
1748 assert_eq!(w.stepper_state.inner.total_steps, 1);
1749 }
1750 }
1751
1752 #[test]
1753 fn create_derives_total_steps_from_the_label_count_and_starts_at_zero() {
1754 for n in [0usize, 1, 2, 7, 4096] {
1755 let s = Stepper::create(n_labels(n));
1756 assert_eq!(s.stepper_state.inner.total_steps, n, "n={n}: total_steps");
1757 assert_eq!(s.stepper_state.inner.current_step, 0, "n={n}: a fresh stepper starts at 0");
1758 assert_eq!(s.labels.as_ref().len(), n, "n={n}: label count");
1759 assert!(s.stepper_state.on_step_change.as_ref().is_none(), "n={n}: create wires no callback");
1760 }
1761 }
1762
1763 #[test]
1764 fn create_keeps_total_steps_and_the_label_count_in_lockstep() {
1765 for n in [0usize, 1, 3, 64] {
1769 let s = Stepper::create(n_labels(n));
1770 assert_eq!(
1771 s.stepper_state.inner.total_steps,
1772 s.labels.as_ref().len(),
1773 "n={n}: total_steps drifted from the label count"
1774 );
1775 }
1776 }
1777
1778 #[test]
1779 fn create_installs_the_shared_container_style() {
1780 let s = stepper(&["a", "b"]);
1781 assert_eq!(
1782 s.container_style.as_ref(),
1783 STEPPER_CONTAINER_STYLE,
1784 "create must install the shared container style"
1785 );
1786
1787 let props = properties(&s.container_style);
1788 assert_eq!(props.len(), 4);
1789 assert!(props.iter().any(|p| matches!(
1790 p, CssProperty::Display(d) if d.get_property() == Some(&LayoutDisplay::Flex))));
1791 assert!(props.iter().any(|p| matches!(
1792 p, CssProperty::FlexDirection(d)
1793 if d.get_property() == Some(&LayoutFlexDirection::Row))));
1794 assert!(props.iter().any(|p| matches!(
1795 p, CssProperty::AlignItems(a) if a.get_property() == Some(&LayoutAlignItems::Start))),
1796 "step cells must be top-aligned so labels of different heights do not shift the circles");
1797 assert_eq!(
1798 flex_grow_value(&props),
1799 Some(0.0),
1800 "the stepper hugs its steps instead of filling the parent"
1801 );
1802 assert_unconditional_and_unique(&s.container_style, "STEPPER_CONTAINER_STYLE");
1803 }
1804
1805 #[test]
1806 fn create_with_no_labels_equals_default() {
1807 let empty = Stepper::create(StringVec::from_const_slice(&[]));
1808 assert_eq!(empty, Stepper::default(), "Default must be the empty stepper");
1809 assert_eq!(empty.labels.as_ref().len(), 0);
1810 assert_eq!(empty.stepper_state.inner, StepperState { current_step: 0, total_steps: 0 });
1811 }
1812
1813 #[test]
1814 fn create_scales_to_a_very_long_label_list() {
1815 let n = 4096;
1816 let s = Stepper::create(n_labels(n));
1817 assert_eq!(s.labels.as_ref().len(), n);
1818 assert_eq!(s.labels.as_ref()[n - 1].as_str(), format!("s{}", n - 1));
1819 assert_eq!(s.stepper_state.inner.total_steps, n);
1820 }
1821
1822 #[test]
1827 fn set_current_step_clamps_every_boundary_value_into_range() {
1828 for i in boundary_indices() {
1829 let mut s = stepper(&["a", "b", "c"]);
1830 s.set_current_step(i);
1831 assert_eq!(
1832 s.stepper_state.inner.current_step,
1833 i.min(2),
1834 "index {i} was not clamped to [0, total_steps - 1]"
1835 );
1836 }
1837 }
1838
1839 #[test]
1840 fn set_current_step_on_an_empty_stepper_never_underflows() {
1841 let mut s = Stepper::default();
1844 for i in boundary_indices() {
1845 s.set_current_step(i);
1846 assert_eq!(s.stepper_state.inner.current_step, 0, "index {i} on an empty stepper");
1847 assert_eq!(s.stepper_state.inner.total_steps, 0);
1848 }
1849 }
1850
1851 #[test]
1852 fn set_current_step_at_zero_and_at_the_last_index_is_exact() {
1853 for n in [1usize, 2, 5, 64] {
1854 let mut s = Stepper::create(n_labels(n));
1855 s.set_current_step(0);
1856 assert_eq!(s.stepper_state.inner.current_step, 0, "n={n}: zero");
1857 s.set_current_step(n - 1);
1858 assert_eq!(s.stepper_state.inner.current_step, n - 1, "n={n}: last index is in range");
1859 s.set_current_step(n);
1860 assert_eq!(s.stepper_state.inner.current_step, n - 1, "n={n}: one past the end clamps");
1861 }
1862 }
1863
1864 #[test]
1865 fn set_current_step_on_a_single_step_stepper_is_always_zero() {
1866 let mut s = stepper(&["only"]);
1867 for i in boundary_indices() {
1868 s.set_current_step(i);
1869 assert_eq!(s.stepper_state.inner.current_step, 0, "index {i} on a one-step stepper");
1870 }
1871 }
1872
1873 #[test]
1874 fn set_current_step_is_idempotent_and_last_write_wins() {
1875 let mut s = stepper(&["a", "b", "c"]);
1876 for _ in 0..3 {
1877 s.set_current_step(1);
1878 }
1879 assert_eq!(s.stepper_state.inner.current_step, 1);
1880
1881 for i in [0usize, usize::MAX, 2, 0] {
1882 s.set_current_step(i);
1883 }
1884 assert_eq!(s.stepper_state.inner.current_step, 0, "the last write must win");
1885 }
1886
1887 #[test]
1888 fn set_current_step_leaves_every_other_field_alone() {
1889 let mut s =
1890 stepper(&["a", "b"]).with_on_step_change(RefAny::new(7u8), cb(step_do_nothing));
1891 let before = s.clone();
1892
1893 s.set_current_step(usize::MAX);
1894
1895 assert_eq!(s.labels, before.labels, "labels changed");
1896 assert_eq!(s.container_style, before.container_style, "container style changed");
1897 assert_eq!(
1898 s.stepper_state.inner.total_steps, before.stepper_state.inner.total_steps,
1899 "total_steps changed"
1900 );
1901 assert!(s.stepper_state.on_step_change.as_ref().is_some(), "the callback was dropped");
1902 }
1903
1904 #[test]
1905 fn set_current_step_clamps_against_total_steps_not_the_label_count() {
1906 let mut s = stepper(&["a", "b", "c"]);
1909 s.stepper_state.inner.total_steps = 100;
1910 s.set_current_step(50);
1911 assert_eq!(
1912 s.stepper_state.inner.current_step, 50,
1913 "the clamp follows total_steps, not labels.len()"
1914 );
1915 s.set_current_step(usize::MAX);
1916 assert_eq!(s.stepper_state.inner.current_step, 99);
1917 assert_eq!(s.labels.as_ref().len(), 3, "the setter must not touch the labels");
1918 }
1919
1920 #[test]
1925 fn with_current_step_round_trips_through_the_setter() {
1926 for i in boundary_indices() {
1927 let via_builder = stepper(&["a", "b", "c"]).with_current_step(i);
1928 let mut via_setter = stepper(&["a", "b", "c"]);
1929 via_setter.set_current_step(i);
1930
1931 assert_eq!(via_builder, via_setter, "index {i}: builder and setter diverge");
1932 assert_eq!(via_builder.stepper_state.inner.current_step, i.min(2));
1933 }
1934 }
1935
1936 #[test]
1937 fn with_current_step_stores_exactly_what_it_reads_back_in_range() {
1938 let n = 6;
1940 for i in 0..n {
1941 let s = Stepper::create(n_labels(n)).with_current_step(i);
1942 assert_eq!(s.stepper_state.inner.current_step, i, "step {i} did not round-trip");
1943 assert_eq!(s.stepper_state.inner.total_steps, n);
1944 }
1945 }
1946
1947 #[test]
1948 fn with_current_step_does_not_panic_on_extreme_arguments() {
1949 for n in [0usize, 1, 3] {
1950 for i in boundary_indices() {
1951 let s = Stepper::create(n_labels(n)).with_current_step(i);
1952 let cur = s.stepper_state.inner.current_step;
1953 assert!(
1954 n == 0 && cur == 0 || n > 0 && cur < n,
1955 "n={n}, i={i}: current_step {cur} escaped the valid range"
1956 );
1957 }
1958 }
1959 }
1960
1961 #[test]
1962 fn with_current_step_preserves_the_rest_of_the_widget() {
1963 let base = stepper(&["a", "b", "c"]);
1964 let built = base.clone().with_current_step(2);
1965
1966 assert_eq!(built.labels, base.labels);
1967 assert_eq!(built.container_style, base.container_style);
1968 assert_eq!(built.labels.as_ref().len(), 3, "len/contents must stay consistent");
1969 assert_eq!(built.stepper_state.inner.total_steps, 3);
1970 assert!(built.stepper_state.on_step_change.as_ref().is_none());
1971 }
1972
1973 #[test]
1974 fn with_current_step_chains_with_last_wins() {
1975 let s = stepper(&["a", "b", "c"])
1976 .with_current_step(usize::MAX)
1977 .with_current_step(0)
1978 .with_current_step(1);
1979 assert_eq!(s.stepper_state.inner.current_step, 1);
1980 }
1981
1982 #[test]
1987 fn swap_with_default_returns_the_original_and_leaves_a_default_behind() {
1988 let mut s = stepper(&["Account", "Address", "Payment"]).with_current_step(2);
1989 let expected = s.clone();
1990
1991 let taken = s.swap_with_default();
1992
1993 assert_eq!(taken, expected, "the caller must get the original widget back");
1994 assert_eq!(s, Stepper::default(), "a default must be left in its place");
1995 assert_eq!(s.labels.as_ref().len(), 0);
1996 assert_eq!(s.stepper_state.inner, StepperState { current_step: 0, total_steps: 0 });
1997 }
1998
1999 #[test]
2000 fn swap_with_default_moves_the_callback_out_with_the_widget() {
2001 let mut s = stepper(&["a", "b"]).with_on_step_change(RefAny::new(1u8), cb(record_step));
2002
2003 let taken = s.swap_with_default();
2004
2005 assert!(
2006 taken.stepper_state.on_step_change.as_ref().is_some(),
2007 "the callback must travel with the taken widget"
2008 );
2009 assert!(
2010 s.stepper_state.on_step_change.as_ref().is_none(),
2011 "the leftover default must not keep a handle on the callback"
2012 );
2013 }
2014
2015 #[test]
2016 fn swap_with_default_on_a_default_is_a_no_op() {
2017 let mut s = Stepper::default();
2018 let taken = s.swap_with_default();
2019 assert_eq!(taken, Stepper::default());
2020 assert_eq!(s, Stepper::default());
2021 }
2022
2023 #[test]
2024 fn swap_with_default_twice_yields_a_default_the_second_time() {
2025 let mut s = stepper(&["a", "b"]).with_current_step(1);
2026 let first = s.swap_with_default();
2027 let second = s.swap_with_default();
2028
2029 assert_eq!(first.labels.as_ref().len(), 2);
2030 assert_eq!(first.stepper_state.inner.current_step, 1);
2031 assert_eq!(second, Stepper::default(), "the second take is the default we left behind");
2032 assert_eq!(s, Stepper::default());
2033 }
2034
2035 #[test]
2036 fn swap_with_default_does_not_truncate_a_large_label_list() {
2037 let n = 1024;
2038 let mut s = Stepper::create(n_labels(n)).with_current_step(n - 1);
2039 let taken = s.swap_with_default();
2040
2041 assert_eq!(taken.labels.as_ref().len(), n);
2042 assert_eq!(taken.labels.as_ref()[n - 1].as_str(), format!("s{}", n - 1));
2043 assert_eq!(taken.stepper_state.inner.current_step, n - 1);
2044 assert_eq!(taken.stepper_state.inner.total_steps, n);
2045 }
2046
2047 #[test]
2052 fn set_on_step_change_installs_the_callback_and_shares_its_payload() {
2053 let mut s = stepper(&["a", "b"]);
2054 let mut payload = RefAny::new(StepLog { seen: Vec::new() });
2055 s.set_on_step_change(payload.clone(), cb(record_step));
2056
2057 let installed = s
2058 .stepper_state
2059 .on_step_change
2060 .as_ref()
2061 .expect("set_on_step_change must install a callback");
2062 assert_eq!(installed.callback.cb as usize, record_step as usize, "wrong function installed");
2063 assert!(
2064 matches!(installed.callback.ctx, OptionRefAny::None),
2065 "a native Rust callback carries no FFI context"
2066 );
2067
2068 let mut stored = installed.refany.clone();
2071 {
2072 let mut log = stored.downcast_mut::<StepLog>().expect("payload type must survive");
2073 log.seen.push(StepperState { current_step: 42, total_steps: 43 });
2074 }
2075 assert_eq!(
2076 logged(&mut payload),
2077 vec![StepperState { current_step: 42, total_steps: 43 }],
2078 "the payload was copied, not shared"
2079 );
2080 }
2081
2082 #[test]
2083 fn set_on_step_change_overwrites_a_previously_installed_callback() {
2084 let mut s = stepper(&["a", "b"]);
2085 s.set_on_step_change(RefAny::new(1u8), cb(record_step));
2086 s.set_on_step_change(RefAny::new(2u8), cb(step_refresh_all));
2087
2088 let installed = s.stepper_state.on_step_change.as_ref().expect("callback");
2089 assert_eq!(
2090 installed.callback.cb as usize, step_refresh_all as usize,
2091 "the last setter must win"
2092 );
2093 assert_ne!(installed.callback.cb as usize, record_step as usize);
2094 }
2095
2096 #[test]
2097 fn set_on_step_change_does_not_disturb_the_labels_or_the_state() {
2098 let mut s = stepper(&["a", "b", "c"]).with_current_step(2);
2099 s.set_on_step_change(RefAny::new(0u8), cb(step_do_nothing));
2100
2101 assert_eq!(s.labels.as_ref().len(), 3);
2102 assert_eq!(
2103 s.stepper_state.inner,
2104 StepperState { current_step: 2, total_steps: 3 },
2105 "installing a callback moved the current step"
2106 );
2107 }
2108
2109 #[test]
2110 fn set_on_step_change_accepts_an_arbitrary_payload_without_reading_it() {
2111 for payload in [RefAny::new(0u8), RefAny::new(String::new()), RefAny::new([0u64; 64])] {
2112 let mut s = stepper(&["a"]);
2113 s.set_on_step_change(payload, cb(step_do_nothing));
2114 assert!(s.stepper_state.on_step_change.as_ref().is_some());
2115 }
2116 }
2117
2118 #[test]
2119 fn with_on_step_change_matches_the_setter_exactly() {
2120 let payload = RefAny::new(9u8);
2121 let via_builder =
2122 stepper(&["a", "b"]).with_on_step_change(payload.clone(), cb(step_do_nothing));
2123 let mut via_setter = stepper(&["a", "b"]);
2124 via_setter.set_on_step_change(payload, cb(step_do_nothing));
2125
2126 assert_eq!(via_builder, via_setter, "builder and setter must produce the same widget");
2127 }
2128
2129 #[test]
2130 fn with_on_step_change_holds_its_invariants_after_construction() {
2131 let s = Stepper::create(n_labels(5))
2132 .with_current_step(3)
2133 .with_on_step_change(RefAny::new(0u8), cb(step_refresh_all));
2134
2135 assert_eq!(s.labels.as_ref().len(), 5, "label count must survive the builder chain");
2136 assert_eq!(
2137 s.stepper_state.inner,
2138 StepperState { current_step: 3, total_steps: 5 },
2139 "the state must survive"
2140 );
2141 assert_eq!(
2142 s.container_style.as_ref(),
2143 STEPPER_CONTAINER_STYLE,
2144 "the container style must survive"
2145 );
2146 let installed = s.stepper_state.on_step_change.as_ref().expect("callback");
2147 assert_eq!(installed.callback.cb as usize, step_refresh_all as usize);
2148 }
2149
2150 #[test]
2151 fn with_on_step_change_chains_with_last_wins() {
2152 let s = stepper(&["a"])
2153 .with_on_step_change(RefAny::new(0u8), cb(record_step))
2154 .with_on_step_change(RefAny::new(0u8), cb(step_do_nothing));
2155 let installed = s.stepper_state.on_step_change.as_ref().expect("callback");
2156 assert_eq!(installed.callback.cb as usize, step_do_nothing as usize);
2157 }
2158
2159 #[test]
2164 fn dom_emits_one_six_node_cell_per_label_in_order() {
2165 let case = ["Account", "Address", "Payment", "Review"];
2166 let dom = Stepper::create(labels(&case)).dom();
2167
2168 assert!(matches!(dom.root.get_node_type(), NodeType::Div), "the stepper is a div");
2169 assert!(dom.root.has_class("__azul-native-stepper"));
2170 assert!(
2171 dom.root.get_callbacks().as_ref().is_empty(),
2172 "the container itself is not clickable"
2173 );
2174
2175 let children = dom.children.as_ref();
2176 assert_eq!(children.len(), case.len());
2177 for (i, cell) in children.iter().enumerate() {
2178 assert!(cell.root.has_class("__azul-native-stepper-step"), "step {i}: cell class");
2179 assert_eq!(cell.children.as_ref().len(), 2, "step {i}: cell holds a row + a label");
2180
2181 let row = row_of(cell);
2182 assert!(row.root.has_class("__azul-native-stepper-row"), "step {i}: row class");
2183 assert_eq!(row.children.as_ref().len(), 3, "step {i}: connector, circle, connector");
2184
2185 assert!(
2186 conn_left_of(row).root.has_class("__azul-native-stepper-connector"),
2187 "step {i}: left connector class"
2188 );
2189 assert!(
2190 conn_right_of(row).root.has_class("__azul-native-stepper-connector"),
2191 "step {i}: right connector class"
2192 );
2193 assert!(
2194 circle_of(row).root.has_class("__azul-native-stepper-circle"),
2195 "step {i}: circle class"
2196 );
2197 assert!(
2198 label_of(cell).root.has_class("__azul-native-stepper-label"),
2199 "step {i}: label class"
2200 );
2201 assert_eq!(text_of(label_of(cell)), Some(case[i]), "step {i}: wrong caption");
2202 }
2203 }
2204
2205 #[test]
2206 fn dom_numbers_the_circles_from_one() {
2207 let n = 12;
2210 let dom = Stepper::create(n_labels(n)).dom();
2211 for (i, cell) in dom.children.as_ref().iter().enumerate() {
2212 let want = format!("{}", i + 1);
2213 assert_eq!(
2214 text_of(circle_of(row_of(cell))),
2215 Some(want.as_str()),
2216 "step {i} shows the wrong number"
2217 );
2218 }
2219 }
2220
2221 #[test]
2222 fn dom_of_an_empty_stepper_is_a_childless_container() {
2223 let dom = Stepper::default().dom();
2226 assert_eq!(dom.children.as_ref().len(), 0);
2227 assert_eq!(dom.estimated_total_children, 0);
2228 assert!(dom.root.has_class("__azul-native-stepper"));
2229
2230 let styled = StyledDom::create_from_dom(dom);
2231 assert_eq!(styled.node_hierarchy.as_ref().len(), 1, "just the container");
2232 }
2233
2234 #[test]
2235 fn dom_styles_every_circle_and_label_by_reached_ness() {
2236 for n in [1usize, 2, 3, 5] {
2237 for current in 0..n {
2238 let dom = Stepper::create(n_labels(n)).with_current_step(current).dom();
2239 for (i, cell) in dom.children.as_ref().iter().enumerate() {
2240 let reached = i <= current;
2241 assert_eq!(
2242 inline_properties(circle_of(row_of(cell))),
2243 properties(&circle_style(reached)),
2244 "n={n} current={current}: circle {i} carries the wrong style"
2245 );
2246 assert_eq!(
2247 inline_properties(label_of(cell)),
2248 properties(&label_style(reached)),
2249 "n={n} current={current}: label {i} carries the wrong style"
2250 );
2251 }
2252 }
2253 }
2254 }
2255
2256 #[test]
2257 fn dom_paints_the_connectors_exactly_as_the_fill_helpers_say() {
2258 for n in [1usize, 2, 3, 6] {
2259 let last = n - 1;
2260 for current in 0..n {
2261 let dom = Stepper::create(n_labels(n)).with_current_step(current).dom();
2262 for (i, cell) in dom.children.as_ref().iter().enumerate() {
2263 let row = row_of(cell);
2264 assert_eq!(
2265 background_color(&inline_properties(conn_left_of(row))),
2266 Some(fill_color(conn_left_fill(i, current))),
2267 "n={n} current={current}: left connector of step {i}"
2268 );
2269 assert_eq!(
2270 background_color(&inline_properties(conn_right_of(row))),
2271 Some(fill_color(conn_right_fill(i, last, current))),
2272 "n={n} current={current}: right connector of step {i}"
2273 );
2274 }
2275 }
2276 }
2277 }
2278
2279 #[test]
2280 fn dom_hides_the_two_outer_connectors_of_every_stepper() {
2281 for n in [1usize, 2, 5] {
2282 for current in 0..n {
2283 let dom = Stepper::create(n_labels(n)).with_current_step(current).dom();
2284 let children = dom.children.as_ref();
2285
2286 let first_row = row_of(&children[0]);
2287 assert_eq!(
2288 background_color(&inline_properties(conn_left_of(first_row))),
2289 Some(TRANSPARENT_COLOR),
2290 "n={n} current={current}: the track sticks out to the left of step 1"
2291 );
2292
2293 let last_row = row_of(&children[n - 1]);
2294 assert_eq!(
2295 background_color(&inline_properties(conn_right_of(last_row))),
2296 Some(TRANSPARENT_COLOR),
2297 "n={n} current={current}: the track sticks out past the final step"
2298 );
2299 }
2300 }
2301 }
2302
2303 #[test]
2304 fn dom_of_a_single_step_hides_both_of_its_connectors() {
2305 let dom = stepper(&["only"]).dom();
2306 let row = row_of(step_cell(&dom, 0));
2307 assert_eq!(
2308 background_color(&inline_properties(conn_left_of(row))),
2309 Some(TRANSPARENT_COLOR)
2310 );
2311 assert_eq!(
2312 background_color(&inline_properties(conn_right_of(row))),
2313 Some(TRANSPARENT_COLOR)
2314 );
2315 assert_eq!(
2316 inline_properties(circle_of(row)),
2317 properties(&circle_style(true)),
2318 "a lone step is always the current one"
2319 );
2320 }
2321
2322 #[test]
2323 fn dom_marks_a_contiguous_reached_prefix() {
2324 for n in [1usize, 4, 9] {
2327 for current in 0..n {
2328 let dom = Stepper::create(n_labels(n)).with_current_step(current).dom();
2329 let accent: Vec<usize> = dom
2330 .children
2331 .as_ref()
2332 .iter()
2333 .enumerate()
2334 .filter(|(_, cell)| {
2335 background_color(&inline_properties(circle_of(row_of(cell))))
2336 == Some(ACCENT_COLOR)
2337 })
2338 .map(|(i, _)| i)
2339 .collect();
2340 assert_eq!(
2341 accent,
2342 (0..=current).collect::<Vec<_>>(),
2343 "n={n} current={current}: the reached prefix is not contiguous"
2344 );
2345 }
2346 }
2347 }
2348
2349 #[test]
2350 fn dom_with_an_out_of_range_current_step_renders_fully_complete_without_panicking() {
2351 for current in [3usize, 4, 1_000, usize::MAX - 1, usize::MAX] {
2355 let mut s = Stepper::create(n_labels(3));
2356 s.stepper_state.inner.current_step = current;
2357 let dom = s.dom();
2358
2359 assert_eq!(dom.children.as_ref().len(), 3, "current={current}: child count changed");
2360 for (i, cell) in dom.children.as_ref().iter().enumerate() {
2361 assert_eq!(
2362 inline_properties(circle_of(row_of(cell))),
2363 properties(&circle_style(true)),
2364 "current={current}: circle {i} must render reached"
2365 );
2366 }
2367 let children = dom.children.as_ref();
2369 assert_eq!(
2370 background_color(&inline_properties(conn_left_of(row_of(&children[0])))),
2371 Some(TRANSPARENT_COLOR),
2372 "current={current}: leading edge"
2373 );
2374 assert_eq!(
2375 background_color(&inline_properties(conn_right_of(row_of(&children[2])))),
2376 Some(TRANSPARENT_COLOR),
2377 "current={current}: trailing edge"
2378 );
2379 }
2380 }
2381
2382 #[test]
2383 fn dom_ignores_a_total_steps_that_disagrees_with_the_label_count() {
2384 let mut s = stepper(&["a", "b", "c"]);
2387 s.stepper_state.inner.total_steps = 99;
2388 let dom = s.dom();
2389 assert_eq!(dom.children.as_ref().len(), 3);
2390 assert_eq!(
2391 background_color(&inline_properties(conn_right_of(row_of(step_cell(&dom, 2))))),
2392 Some(TRANSPARENT_COLOR),
2393 "`last` must come from the rendered label count, not from total_steps"
2394 );
2395 }
2396
2397 #[test]
2398 fn dom_makes_every_step_clickable_and_keyboard_reachable() {
2399 let n = 3;
2400 let dom = Stepper::create(n_labels(n)).dom();
2401 for (i, cell) in dom.children.as_ref().iter().enumerate() {
2402 let cbs = cell.root.get_callbacks();
2403 assert_eq!(cbs.as_ref().len(), 1, "step {i}: exactly one handler");
2404 assert_eq!(cbs.as_ref()[0].event, EventFilter::Hover(HoverEventFilter::MouseUp));
2405 assert_eq!(cbs.as_ref()[0].callback.cb, on_step_click as usize);
2406 assert!(matches!(cbs.as_ref()[0].callback.ctx, OptionRefAny::None));
2407 assert_eq!(
2408 cell.root.get_tab_index(),
2409 Some(TabIndex::Auto),
2410 "step {i} must be tab-reachable"
2411 );
2412
2413 let row = row_of(cell);
2416 for (name, inner) in [
2417 ("row", row),
2418 ("conn-left", conn_left_of(row)),
2419 ("circle", circle_of(row)),
2420 ("conn-right", conn_right_of(row)),
2421 ("label", label_of(cell)),
2422 ] {
2423 assert!(
2424 inner.root.get_callbacks().as_ref().is_empty(),
2425 "step {i}: the {name} node must not carry its own handler"
2426 );
2427 }
2428 }
2429 }
2430
2431 #[test]
2432 fn dom_shares_one_state_refany_across_every_step() {
2433 let dom = Stepper::create(n_labels(4)).dom();
2437
2438 let mut first = step_state(&dom, 0);
2439 {
2440 let mut w = first
2441 .downcast_mut::<StepperStateWrapper>()
2442 .expect("step state must be a StepperStateWrapper");
2443 w.inner.current_step = 3;
2444 }
2445
2446 for i in 1..4 {
2447 let mut other = step_state(&dom, i);
2448 assert_eq!(current_step_of(&mut other), 3, "step {i} does not share step 0's state");
2449 }
2450 }
2451
2452 #[test]
2453 fn dom_carries_the_installed_callback_and_the_total_into_the_shared_state() {
2454 let dom = Stepper::create(n_labels(4))
2455 .with_current_step(2)
2456 .with_on_step_change(RefAny::new(0u8), cb(step_refresh_all))
2457 .dom();
2458 let mut state = step_state(&dom, 0);
2459 let wrapper = state.downcast_ref::<StepperStateWrapper>().expect("StepperStateWrapper");
2460
2461 assert_eq!(wrapper.inner, StepperState { current_step: 2, total_steps: 4 });
2462 let installed = wrapper.on_step_change.as_ref().expect("the callback must reach the DOM");
2463 assert_eq!(installed.callback.cb as usize, step_refresh_all as usize);
2464 }
2465
2466 #[test]
2467 fn dom_preserves_adversarial_labels_verbatim() {
2468 for s in adversarial_strings() {
2469 let dom = Stepper::create(labels(&[s.as_str(), "other"])).dom();
2470 let children = dom.children.as_ref();
2471 assert_eq!(children.len(), 2);
2472 match label_of(&children[0]).root.get_node_type() {
2473 NodeType::Text(t) => {
2474 assert_eq!(t.as_ref().as_str(), s.as_str(), "the caption changed inside dom()");
2475 assert_eq!(t.as_ref().len(), s.len(), "byte length changed (NUL truncation?)");
2476 }
2477 other => panic!("expected a text node, got {other:?}"),
2478 }
2479 assert_eq!(text_of(circle_of(row_of(&children[0]))), Some("1"));
2481 }
2482 }
2483
2484 #[test]
2485 fn dom_keeps_estimated_total_children_in_sync() {
2486 for n in [0usize, 1, 2, 3, 5, 64, 257] {
2489 let dom = Stepper::create(n_labels(n)).dom();
2490 assert_eq!(dom.children.as_ref().len(), n, "child count for n={n}");
2491 assert_eq!(
2492 dom.estimated_total_children,
2493 recursive_descendants(&dom),
2494 "cached descendant count desynced for n={n}"
2495 );
2496 assert_eq!(
2497 dom.estimated_total_children,
2498 NODES_PER_STEP * n,
2499 "a step must cost exactly {NODES_PER_STEP} nodes (n={n})"
2500 );
2501 }
2502 }
2503
2504 #[test]
2505 fn dom_of_many_steps_flattens_without_panicking() {
2506 let n = 512;
2507 let styled = StyledDom::create_from_dom(Stepper::create(n_labels(n)).dom());
2508 assert_eq!(
2509 styled.node_hierarchy.as_ref().len(),
2510 NODES_PER_STEP * n + 1,
2511 "root + six nodes per step"
2512 );
2513 }
2514
2515 #[test]
2516 fn flattened_layout_is_six_nodes_per_step() {
2517 let n = 4;
2519 let styled = StyledDom::create_from_dom(Stepper::create(n_labels(n)).dom());
2520 let data = styled.node_data.as_container();
2521 assert_eq!(styled.node_hierarchy.as_ref().len(), NODES_PER_STEP * n + 1);
2522
2523 assert!(data
2524 .get(NodeId::new(0))
2525 .expect("root")
2526 .has_class("__azul-native-stepper"));
2527
2528 for i in 0..n {
2529 for (idx, class) in [
2530 (cell_node(i), "__azul-native-stepper-step"),
2531 (row_node(i), "__azul-native-stepper-row"),
2532 (conn_left_node(i), "__azul-native-stepper-connector"),
2533 (circle_node(i), "__azul-native-stepper-circle"),
2534 (conn_right_node(i), "__azul-native-stepper-connector"),
2535 (label_node(i), "__azul-native-stepper-label"),
2536 ] {
2537 let nd = data
2538 .get(NodeId::new(idx))
2539 .unwrap_or_else(|| panic!("step {i}: node {idx} is missing"));
2540 assert!(nd.has_class(class), "step {i}: node {idx} is not a {class}");
2541 }
2542 let want = format!("{}", i + 1);
2543 match data.get(NodeId::new(circle_node(i))).expect("circle").get_node_type() {
2544 NodeType::Text(t) => {
2545 assert_eq!(t.as_ref().as_str(), want.as_str(), "step {i}: circle number");
2546 }
2547 other => panic!("step {i}: the circle is not a text node: {other:?}"),
2548 }
2549 }
2550 }
2551
2552 #[test]
2553 fn dom_via_from_matches_dom_exactly() {
2554 let build = || Stepper::create(n_labels(4)).with_current_step(2);
2555 let via_into: Dom = build().into();
2556 let via_dom = build().dom();
2557
2558 assert_eq!(via_into.children.as_ref().len(), via_dom.children.as_ref().len());
2559 assert_eq!(via_into.estimated_total_children, via_dom.estimated_total_children);
2560 for i in 0..via_dom.children.as_ref().len() {
2561 let a = step_cell(&via_into, i);
2562 let b = step_cell(&via_dom, i);
2563 assert_eq!(inline_properties(a), inline_properties(b), "`From` diverges at cell {i}");
2564 assert_eq!(
2565 inline_properties(circle_of(row_of(a))),
2566 inline_properties(circle_of(row_of(b))),
2567 "`From` diverges at circle {i}"
2568 );
2569 assert_eq!(text_of(label_of(a)), text_of(label_of(b)));
2570 }
2571 }
2572
2573 #[test]
2574 fn dom_with_duplicate_labels_still_produces_distinct_positional_steps() {
2575 let dom = stepper(&["same", "same", "same"]).with_current_step(1).dom();
2577 for (i, cell) in dom.children.as_ref().iter().enumerate() {
2578 assert_eq!(text_of(label_of(cell)), Some("same"));
2579 assert_eq!(
2580 inline_properties(circle_of(row_of(cell))),
2581 properties(&circle_style(i <= 1)),
2582 "step {i}"
2583 );
2584 let want = format!("{}", i + 1);
2585 assert_eq!(
2586 text_of(circle_of(row_of(cell))),
2587 Some(want.as_str()),
2588 "step {i}: the number must still be positional"
2589 );
2590 }
2591 }
2592
2593 #[test]
2594 fn dom_gives_every_cell_the_shared_equal_share_style() {
2595 let dom = Stepper::create(n_labels(3)).dom();
2596 for (i, cell) in dom.children.as_ref().iter().enumerate() {
2597 let props = inline_properties(cell);
2598 assert_eq!(
2599 props,
2600 properties(&CssPropertyWithConditionsVec::from_const_slice(STEPPER_STEP_STYLE)),
2601 "cell {i} does not carry the shared step style"
2602 );
2603 assert_eq!(
2604 flex_grow_value(&props),
2605 Some(1.0),
2606 "cell {i}: steps must spread evenly across the row"
2607 );
2608 assert!(
2609 props.iter().any(|p| matches!(
2610 p, CssProperty::FlexBasis(b)
2611 if matches!(b.get_property(), Some(LayoutFlexBasis::Exact(pv))
2612 if pv.metric == SizeMetric::Px && pv.number.get() == 0.0))),
2613 "cell {i}: flex-basis must be 0 so flex-grow alone decides the share"
2614 );
2615 }
2616 }
2617
2618 #[test]
2623 fn click_moves_to_the_clicked_step_and_restyles_the_whole_row() {
2624 let n = 4;
2625 for clicked in 1..n {
2626 let (styled, state) = flatten(Stepper::create(n_labels(n)));
2627 let mut probe = state.clone();
2628
2629 let (update, changes) = run_click(Some(styled), node(cell_node(clicked)), state);
2630
2631 assert_eq!(
2632 update,
2633 Update::DoNothing,
2634 "with no on_step_change installed the handler reports nothing to redraw"
2635 );
2636 assert_eq!(current_step_of(&mut probe), clicked, "the stored step did not move");
2637 assert_eq!(
2638 restyle_writes(&changes),
2639 expected_restyle(n, clicked),
2640 "clicked={clicked}: the live restyle is wrong"
2641 );
2642 }
2643 }
2644
2645 #[test]
2646 fn click_restyle_agrees_with_a_freshly_built_dom() {
2647 let n = 5;
2650 for clicked in 1..n {
2651 let (styled, state) = flatten(Stepper::create(n_labels(n)));
2652 let (_, changes) = run_click(Some(styled), node(cell_node(clicked)), state);
2653 let writes = restyle_writes(&changes);
2654 assert_eq!(writes.len(), 5 * n, "clicked={clicked}: five writes per step");
2655
2656 let rebuilt = Stepper::create(n_labels(n)).with_current_step(clicked).dom();
2657 for i in 0..n {
2658 let cell = step_cell(&rebuilt, i);
2659 let row = row_of(cell);
2660 let circle = inline_properties(circle_of(row));
2661 let label = inline_properties(label_of(cell));
2662
2663 assert_eq!(
2664 writes[5 * i],
2665 (circle_node(i), "bg", background_color(&circle).expect("circle bg")),
2666 "clicked={clicked}: circle {i} background"
2667 );
2668 assert_eq!(
2669 writes[5 * i + 1],
2670 (circle_node(i), "text", text_color(&circle).expect("circle text")),
2671 "clicked={clicked}: circle {i} number colour"
2672 );
2673 assert_eq!(
2674 writes[5 * i + 2],
2675 (
2676 conn_left_node(i),
2677 "bg",
2678 background_color(&inline_properties(conn_left_of(row)))
2679 .expect("left connector bg")
2680 ),
2681 "clicked={clicked}: left connector {i}"
2682 );
2683 assert_eq!(
2684 writes[5 * i + 3],
2685 (
2686 conn_right_node(i),
2687 "bg",
2688 background_color(&inline_properties(conn_right_of(row)))
2689 .expect("right connector bg")
2690 ),
2691 "clicked={clicked}: right connector {i}"
2692 );
2693 assert_eq!(
2694 writes[5 * i + 4],
2695 (label_node(i), "text", text_color(&label).expect("label text")),
2696 "clicked={clicked}: label {i} colour"
2697 );
2698 }
2699 }
2700 }
2701
2702 #[test]
2703 fn click_on_the_already_current_step_is_a_complete_no_op() {
2704 for current in 0..4usize {
2707 let mut log = RefAny::new(StepLog { seen: Vec::new() });
2708 let s = Stepper::create(n_labels(4))
2709 .with_current_step(current)
2710 .with_on_step_change(log.clone(), cb(record_step));
2711 let (styled, state) = flatten(s);
2712 let mut probe = state.clone();
2713
2714 let (update, changes) = run_click(Some(styled), node(cell_node(current)), state);
2715
2716 assert_eq!(update, Update::DoNothing, "current={current}");
2717 assert!(changes.is_empty(), "current={current}: a no-op click restyled the row");
2718 assert_eq!(current_step_of(&mut probe), current, "current={current}: state moved");
2719 assert!(logged(&mut log).is_empty(), "current={current}: the callback was invoked");
2720 }
2721 }
2722
2723 #[test]
2724 fn click_invokes_the_user_callback_with_the_updated_state() {
2725 let mut log = RefAny::new(StepLog { seen: Vec::new() });
2726 let s = Stepper::create(n_labels(4)).with_on_step_change(log.clone(), cb(record_step));
2727 let (styled, state) = flatten(s);
2728
2729 let (update, changes) = run_click(Some(styled), node(cell_node(2)), state.clone());
2730 assert_eq!(update, Update::RefreshDom, "the user's Update must propagate");
2731 assert_eq!(
2732 logged(&mut log),
2733 vec![StepperState { current_step: 2, total_steps: 4 }],
2734 "the callback must see the *new* step, with the total intact"
2735 );
2736 assert_eq!(restyle_writes(&changes).len(), 20, "the restyle must still run");
2737
2738 let (styled2, _) = flatten(Stepper::create(n_labels(4)));
2740 let (_, _) = run_click(Some(styled2), node(cell_node(1)), state.clone());
2741 assert_eq!(
2742 logged(&mut log),
2743 vec![
2744 StepperState { current_step: 2, total_steps: 4 },
2745 StepperState { current_step: 1, total_steps: 4 },
2746 ]
2747 );
2748
2749 let mut state = state;
2750 assert_eq!(current_step_of(&mut state), 1, "the state holds the *last* clicked step");
2751 }
2752
2753 #[test]
2754 fn click_propagates_every_update_variant_unchanged() {
2755 for (handler, expected) in [
2756 (cb(step_do_nothing), Update::DoNothing),
2757 (cb(step_refresh_all), Update::RefreshDomAllWindows),
2758 ] {
2759 let s = Stepper::create(n_labels(2)).with_on_step_change(RefAny::new(0u8), handler);
2760 let (styled, state) = flatten(s);
2761 let (update, changes) = run_click(Some(styled), node(cell_node(1)), state);
2762 assert_eq!(update, expected);
2763 assert_eq!(
2764 restyle_writes(&changes).len(),
2765 10,
2766 "the restyle runs regardless of what the user returns"
2767 );
2768 }
2769 }
2770
2771 #[test]
2772 fn click_restyles_even_without_a_user_callback() {
2773 let (styled, state) = flatten(stepper(&["a", "b"]));
2774 let (update, changes) = run_click(Some(styled), node(cell_node(1)), state);
2775
2776 assert_eq!(update, Update::DoNothing);
2777 assert_eq!(
2778 restyle_writes(&changes),
2779 expected_restyle(2, 1),
2780 "progress feedback must not depend on the user wiring a callback"
2781 );
2782 }
2783
2784 #[test]
2785 fn click_on_a_single_step_stepper_stays_at_zero() {
2786 let (styled, state) = flatten(stepper(&["only"]));
2787 let mut probe = state.clone();
2788 let (update, changes) = run_click(Some(styled), node(cell_node(0)), state);
2789
2790 assert_eq!(update, Update::DoNothing);
2791 assert!(changes.is_empty(), "a one-step stepper has nothing to restyle");
2792 assert_eq!(current_step_of(&mut probe), 0);
2793 }
2794
2795 #[test]
2796 fn click_can_walk_backwards() {
2797 let n = 5;
2800 let (styled, state) = flatten(Stepper::create(n_labels(n)).with_current_step(4));
2801 let mut probe = state.clone();
2802
2803 let (_, changes) = run_click(Some(styled), node(cell_node(1)), state);
2804
2805 assert_eq!(current_step_of(&mut probe), 1);
2806 assert_eq!(restyle_writes(&changes), expected_restyle(n, 1));
2807 assert!(
2809 restyle_writes(&changes)
2810 .contains(&(circle_node(4), "bg", MUTED_CIRCLE_COLOR)),
2811 "walking back left a stale accent circle behind"
2812 );
2813 }
2814
2815 #[test]
2816 fn click_on_the_root_node_does_nothing() {
2817 let (styled, state) = flatten(stepper(&["a", "b"]));
2819 let mut probe = state.clone();
2820
2821 let (update, changes) = run_click(Some(styled), node(0), state);
2822
2823 assert_eq!(update, Update::DoNothing);
2824 assert!(changes.is_empty(), "a parentless hit pushed a DOM change");
2825 assert_eq!(current_step_of(&mut probe), 0, "the state must be untouched");
2826 }
2827
2828 #[test]
2829 fn click_on_a_stale_or_absent_node_does_nothing() {
2830 for hit in [node(9999), node(usize::MAX - 1), node_none()] {
2834 let (styled, state) = flatten(Stepper::create(n_labels(3)).with_current_step(1));
2835 let mut probe = state.clone();
2836
2837 let (update, changes) = run_click(Some(styled), hit, state);
2838
2839 assert_eq!(update, Update::DoNothing, "{hit:?}: a stale hit was acted on");
2840 assert!(changes.is_empty(), "{hit:?}: a stale hit pushed a DOM change");
2841 assert_eq!(current_step_of(&mut probe), 1, "{hit:?}: a stale hit moved the step");
2842 }
2843 }
2844
2845 #[test]
2846 fn click_with_no_layout_result_does_nothing() {
2847 let dom = stepper(&["a", "b"]).dom();
2848 let state = step_state(&dom, 0);
2849
2850 let (update, changes) = run_click(None, node(cell_node(1)), state);
2851
2852 assert_eq!(
2853 update,
2854 Update::DoNothing,
2855 "an empty LayoutWindow must be handled, not unwrapped"
2856 );
2857 assert!(changes.is_empty());
2858 }
2859
2860 #[test]
2861 fn click_with_a_foreign_payload_does_nothing_and_leaves_it_intact() {
2862 let (styled, _) = flatten(stepper(&["a", "b"]));
2865 let foreign = RefAny::new(0xDEAD_BEEF_u32);
2866
2867 let (update, changes) = run_click(Some(styled), node(cell_node(1)), foreign.clone());
2868
2869 assert_eq!(update, Update::DoNothing);
2870 assert!(
2871 changes.is_empty(),
2872 "a failed downcast must not leave a half-applied restyle"
2873 );
2874 let mut foreign = foreign;
2875 assert_eq!(
2876 *foreign.downcast_ref::<u32>().expect("the foreign payload was reinterpreted"),
2877 0xDEAD_BEEF,
2878 "the handler corrupted a RefAny it did not understand"
2879 );
2880 }
2881
2882 #[test]
2883 fn click_with_the_state_already_borrowed_does_nothing() {
2884 let (styled, state) = flatten(stepper(&["a", "b"]));
2885
2886 let mut held = state.clone();
2889 let guard = held.downcast_mut::<StepperStateWrapper>().expect("first borrow succeeds");
2890
2891 let (update, changes) = run_click(Some(styled), node(cell_node(1)), state);
2892
2893 assert_eq!(update, Update::DoNothing);
2894 assert!(changes.is_empty(), "the handler restyled after failing to read the state");
2895 drop(guard);
2896 }
2897
2898 #[test]
2899 fn click_holds_the_state_borrow_across_the_user_callback() {
2900 let mut probe = RefAny::new(ReentrantProbe {
2908 state: RefAny::new(0u8),
2909 saw_step: Some(usize::MAX),
2910 calls: 0,
2911 });
2912 let s = Stepper::create(n_labels(3))
2913 .with_on_step_change(probe.clone(), cb(probe_state_reentrantly));
2914 let (styled, state) = flatten(s);
2915 {
2916 let mut p = probe.downcast_mut::<ReentrantProbe>().expect("probe");
2917 p.state = state.clone();
2918 }
2919
2920 let (update, changes) = run_click(Some(styled), node(cell_node(2)), state);
2921
2922 assert_eq!(update, Update::DoNothing);
2923 assert_eq!(restyle_writes(&changes).len(), 15, "the restyle must still run");
2924
2925 let p = probe.downcast_ref::<ReentrantProbe>().expect("probe");
2926 assert_eq!(p.calls, 1, "the user callback must run exactly once");
2927 assert_eq!(
2928 p.saw_step, None,
2929 "a re-entrant read of the state must be refused, not aliased"
2930 );
2931 }
2932
2933 #[test]
2934 fn a_hit_inside_a_cell_stays_memory_safe() {
2935 for (hit, expected_step) in [
2943 (row_node(0), 0usize), (row_node(1), 0), (conn_left_node(0), 0), (circle_node(0), 1), (conn_right_node(0), 2), ] {
2949 let (styled, state) = flatten(Stepper::create(n_labels(3)));
2950 let mut probe = state.clone();
2951
2952 let (update, changes) = run_click(Some(styled), node(hit), state);
2953
2954 assert_eq!(update, Update::DoNothing, "node {hit}");
2955 assert!(changes.is_empty(), "node {hit}: an inner hit pushed a partial restyle");
2956 assert_eq!(current_step_of(&mut probe), expected_step, "node {hit}");
2957 }
2958 }
2959
2960 #[test]
2961 fn many_clicks_keep_the_state_and_the_restyle_in_agreement() {
2962 let n = 5;
2965 let (_, state) = flatten(Stepper::create(n_labels(n)));
2966
2967 for click in 0..60usize {
2968 let expected = (click * 2 + 1) % n;
2971 let mut probe = state.clone();
2972 if current_step_of(&mut probe) == expected {
2973 continue;
2974 }
2975
2976 let (styled, _) = flatten(Stepper::create(n_labels(n)));
2977 let (_, changes) = run_click(Some(styled), node(cell_node(expected)), state.clone());
2978
2979 assert_eq!(
2980 current_step_of(&mut probe),
2981 expected,
2982 "click #{click}: the stored step drifted"
2983 );
2984 assert_eq!(
2985 restyle_writes(&changes),
2986 expected_restyle(n, expected),
2987 "click #{click}: the pushed colours disagree with the stored step"
2988 );
2989 }
2990 }
2991
2992 #[test]
2993 fn click_never_touches_total_steps() {
2994 let n = 4;
2995 let (styled, state) = flatten(Stepper::create(n_labels(n)));
2996 let (_, _) = run_click(Some(styled), node(cell_node(3)), state.clone());
2997
2998 let mut state = state;
2999 let wrapper = state.downcast_ref::<StepperStateWrapper>().expect("StepperStateWrapper");
3000 assert_eq!(
3001 wrapper.inner,
3002 StepperState { current_step: 3, total_steps: n },
3003 "a click must move the current step and nothing else"
3004 );
3005 }
3006}