1use tessera_foundation::gesture::TapRecognizer;
7use tessera_ui::{
8 AxisConstraint, Callback, Color, ComputedData, Constraint, Dp, FocusRequester,
9 FocusTraversalPolicy, LayoutResult, MeasurementError, Modifier, Px, PxPosition, PxSize,
10 RenderSlot, State,
11 accesskit::Role,
12 current_frame_nanos,
13 layout::{MeasureScope, layout},
14 modifier::FocusModifierExt as _,
15 provide_context, receive_frame_nanos, remember, tessera, use_context,
16};
17
18use crate::{
19 animation::SelectionAnimation,
20 modifier::{InteractionState, ModifierExt, PointerEventContext, SelectableArgs},
21 ripple_state::{RippleSpec, RippleState},
22 shape_def::Shape,
23 surface::{SurfaceStyle, surface},
24 text::text,
25 theme::{ContentColor, MaterialTheme},
26};
27
28const CONTAINER_HEIGHT: Dp = Dp(64.0);
29const INDICATOR_WIDTH: Dp = Dp(56.0);
30const INDICATOR_HEIGHT: Dp = Dp(32.0);
31const INDICATOR_TO_LABEL_PADDING: Dp = Dp(4.0);
32const INDICATOR_VERTICAL_PADDING: Dp = Dp(4.0);
33
34pub(crate) fn interpolate_color(from: Color, to: Color, progress: f32) -> Color {
35 Color {
36 r: from.r + (to.r - from.r) * progress,
37 g: from.g + (to.g - from.g) * progress,
38 b: from.b + (to.b - from.b) * progress,
39 a: from.a + (to.a - from.a) * progress,
40 }
41}
42
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
45pub enum NavigationBarIconPosition {
46 #[default]
48 Auto,
49 Top,
51 Start,
53}
54
55#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
57pub enum NavigationBarLabelBehavior {
58 #[default]
60 AlwaysShow,
61 SelectedOnly,
63}
64
65#[tessera]
66fn navigation_bar_item_view_content(
67 item: Option<NavigationBarItemDefinition>,
68 selection_fraction: Option<f32>,
69 interaction_state: Option<State<InteractionState>>,
70 ripple_state: Option<State<RippleState>>,
71 focus_requester: Option<FocusRequester>,
72) {
73 let item = item.unwrap_or_default();
74 let selection_fraction = selection_fraction.unwrap_or(0.0);
75 let interaction_state = interaction_state.expect("interaction_state must be set");
76 let ripple_state = ripple_state.expect("ripple_state must be set");
77 let focus_requester = focus_requester.expect("focus_requester must be set");
78 let theme = use_context::<MaterialTheme>()
79 .expect("MaterialTheme must be provided")
80 .get();
81 let scheme = theme.color_scheme;
82 let typography = theme.typography;
83
84 let always_show_label = matches!(item.label_behavior, NavigationBarLabelBehavior::AlwaysShow);
85 let has_label = !item.label.is_empty();
86 let has_icon = item.icon.is_some();
87
88 let indicator_alpha = selection_fraction;
89 let icon_color = interpolate_color(
90 scheme.on_surface_variant,
91 scheme.on_secondary_container,
92 selection_fraction,
93 );
94 let ripple_color = icon_color;
95
96 let label_alpha = if always_show_label {
97 1.0
98 } else {
99 selection_fraction
100 };
101 let label_color_base = interpolate_color(
102 scheme.on_surface_variant,
103 if item.icon_position == NavigationBarIconPosition::Start {
104 scheme.on_secondary_container
105 } else {
106 scheme.secondary
107 },
108 selection_fraction,
109 );
110 let label_color = label_color_base.with_alpha(label_color_base.a * label_alpha);
111
112 let indicator_color = scheme.secondary_container.with_alpha(indicator_alpha);
113
114 layout()
115 .policy(NavigationBarItemLayout {
116 selection_fraction,
117 always_show_label,
118 has_label,
119 has_icon,
120 icon_position: item.icon_position,
121 })
122 .child(move || {
123 surface()
124 .style(SurfaceStyle::Filled {
125 color: indicator_color,
126 })
127 .shape(Shape::CAPSULE)
128 .show_state_layer(false)
129 .show_ripple(false)
130 .child(|| {});
131
132 surface()
133 .style(SurfaceStyle::Filled {
134 color: Color::TRANSPARENT,
135 })
136 .shape(Shape::CAPSULE)
137 .enabled(true)
138 .interaction_state(interaction_state)
139 .focus_requester(focus_requester)
140 .show_focus_indicator(true)
141 .ripple_color(ripple_color)
142 .child(move || {
143 surface()
144 .style(SurfaceStyle::Filled {
145 color: Color::TRANSPARENT,
146 })
147 .shape(Shape::CAPSULE)
148 .modifier(Modifier::new().fill_max_size())
149 .enabled(true)
150 .ripple_color(ripple_color)
151 .show_state_layer(false)
152 .ripple_state(ripple_state)
153 .child(|| {});
154 });
155
156 if let Some(draw_icon) = item.icon {
157 provide_context(
158 || ContentColor {
159 current: icon_color,
160 },
161 || {
162 let color = use_context::<ContentColor>().unwrap();
163 if color.with(|value| value.current) != icon_color {
164 color.set(ContentColor {
165 current: icon_color,
166 });
167 }
168 draw_icon.render();
169 },
170 );
171 }
172
173 if has_label {
174 let label = item.label.clone();
175 provide_context(
176 || typography.label_medium,
177 move || {
178 text(label.clone())
179 .color(label_color)
180 .style(typography.label_medium);
181 },
182 );
183 }
184 });
185}
186
187#[derive(Clone, PartialEq)]
188struct NavigationBarItemLayout {
189 selection_fraction: f32,
190 always_show_label: bool,
191 has_label: bool,
192 has_icon: bool,
193 icon_position: NavigationBarIconPosition,
194}
195
196impl tessera_ui::LayoutModifierNode for NavigationBarItemLayout {
197 fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
198 let mut result = LayoutResult::default();
199 let children = input.children();
200 let parent_width = input
201 .parent_constraint()
202 .width()
203 .resolve_max()
204 .unwrap_or(Px::ZERO);
205
206 let min_height = CONTAINER_HEIGHT.to_px();
207
208 let indicator_background = children[0];
209 let indicator_ripple = children[1];
210 let mut child_index = 2;
211
212 let icon_id = if self.has_icon {
213 let id = children[child_index];
214 child_index += 1;
215 Some(id)
216 } else {
217 None
218 };
219
220 let label_id = if self.has_label {
221 let id = children[child_index];
222 Some(id)
223 } else {
224 None
225 };
226
227 let child_constraint = Constraint::NONE;
228
229 let icon_size = if let Some(icon_id) = icon_id {
230 Some(icon_id.measure(input, &child_constraint)?)
231 } else {
232 None
233 };
234
235 let horizontal = self.icon_position == NavigationBarIconPosition::Start && self.has_label;
236 let label_size = if let Some(label_id) = label_id {
237 let reserved = if horizontal {
238 icon_size.map_or(Px::ZERO, |size| size.width) + Dp(36.0).to_px()
239 } else {
240 Dp(8.0).to_px()
241 };
242 Some(label_id.measure(
243 input,
244 &Constraint::new(
245 AxisConstraint::new(Px::ZERO, Some((parent_width - reserved).max(Px::ZERO))),
246 AxisConstraint::NONE,
247 ),
248 )?)
249 } else {
250 None
251 };
252
253 let width = parent_width;
254 let icon_height = icon_size.map_or(Px::ZERO, |size| size.height);
255 let label_height = label_size.map_or(Px::ZERO, |size| size.height);
256 let natural_height = if horizontal {
257 icon_height.max(label_height) + Dp(40.0).to_px()
258 } else if self.has_label {
259 icon_height
260 + INDICATOR_VERTICAL_PADDING.to_px() * 2
261 + INDICATOR_TO_LABEL_PADDING.to_px()
262 + label_height
263 + Dp(12.0).to_px()
264 } else {
265 INDICATOR_HEIGHT.to_px() + Dp(12.0).to_px()
266 };
267 let height = input
268 .parent_constraint()
269 .height()
270 .clamp(min_height.max(natural_height));
271 let indicator_width = if horizontal {
272 icon_size.map_or(Px::ZERO, |size| size.width)
273 + label_size.map_or(Px::ZERO, |size| size.width)
274 + Dp(36.0).to_px()
275 } else {
276 INDICATOR_WIDTH.to_px()
277 };
278 let indicator_height = if horizontal {
279 Dp(40.0).to_px().max(label_height + Dp(16.0).to_px())
280 } else {
281 INDICATOR_HEIGHT.to_px()
282 };
283 let animated_width =
284 Px((indicator_width.to_f32() * self.selection_fraction).round() as i32);
285 let indicator_size = indicator_background.measure(
286 input,
287 &Constraint::new(
288 AxisConstraint::exact(animated_width),
289 AxisConstraint::exact(indicator_height),
290 ),
291 )?;
292 let indicator_ripple_size = indicator_ripple.measure(
293 input,
294 &Constraint::new(
295 AxisConstraint::exact(indicator_width),
296 AxisConstraint::exact(indicator_height),
297 ),
298 )?;
299 if horizontal {
300 let x = (width - indicator_width) / 2;
301 let y = (height - indicator_height) / 2;
302 result.place_child(
303 indicator_background,
304 PxPosition::new((width - animated_width) / 2, y),
305 );
306 result.place_child(indicator_ripple, PxPosition::new(x, y));
307 let icon_width = icon_size.map_or(Px::ZERO, |size| size.width);
308 if let (Some(id), Some(size)) = (icon_id, icon_size) {
309 result.place_child(
310 id,
311 PxPosition::new(x + Dp(16.0).to_px(), (height - size.height) / 2),
312 );
313 }
314 if let (Some(id), Some(size)) = (label_id, label_size) {
315 result.place_child(
316 id,
317 PxPosition::new(
318 x + Dp(20.0).to_px() + icon_width,
319 (height - size.height) / 2,
320 ),
321 );
322 }
323 return Ok(result.with_size(ComputedData { width, height }));
324 }
325
326 if !self.has_label {
327 let ripple_x = (width - indicator_ripple_size.width) / 2;
328 let ripple_y = (height - indicator_ripple_size.height) / 2;
329 let indicator_x = (width - indicator_size.width) / 2;
330 let indicator_y = (height - indicator_size.height) / 2;
331 result.place_child(
332 indicator_background,
333 PxPosition::new(indicator_x, indicator_y),
334 );
335 result.place_child(indicator_ripple, PxPosition::new(ripple_x, ripple_y));
336
337 if let (Some(icon_id), Some(icon_size)) = (icon_id, icon_size) {
338 let icon_x = (width - icon_size.width) / 2;
339 let icon_y = (height - icon_size.height) / 2;
340 result.place_child(icon_id, PxPosition::new(icon_x, icon_y));
341 }
342
343 return Ok(result.with_size(ComputedData { width, height }));
344 }
345
346 let icon_size = icon_size.map(|size| size.size()).unwrap_or(ComputedData {
347 width: Px::ZERO,
348 height: Px::ZERO,
349 });
350 let label_size = label_size.map(|size| size.size()).unwrap_or(ComputedData {
351 width: Px::ZERO,
352 height: Px::ZERO,
353 });
354
355 let indicator_vertical_padding_px = INDICATOR_VERTICAL_PADDING.to_px();
356 let content_height = icon_size.height
357 + indicator_vertical_padding_px * 2
358 + INDICATOR_TO_LABEL_PADDING.to_px()
359 + label_size.height;
360
361 let content_vertical_padding =
362 ((height - content_height) / 2).max(indicator_vertical_padding_px);
363 let selected_icon_y = content_vertical_padding + indicator_vertical_padding_px;
364 let unselected_icon_y = if self.always_show_label {
365 selected_icon_y
366 } else {
367 (height - icon_size.height) / 2
368 };
369
370 let icon_distance = unselected_icon_y - selected_icon_y;
371 let offset =
372 Px(((icon_distance.0 as f32) * (1.0 - self.selection_fraction)).round() as i32);
373
374 let icon_x = (width - icon_size.width) / 2;
375 let label_x = (width - label_size.width) / 2;
376 let ripple_x = (width - indicator_ripple_size.width) / 2;
377 let indicator_x = (width - indicator_size.width) / 2;
378
379 let ripple_y = selected_icon_y - indicator_vertical_padding_px;
380 let indicator_y = selected_icon_y - indicator_vertical_padding_px;
381 let icon_y = selected_icon_y;
382 let label_y = selected_icon_y
383 + icon_size.height
384 + indicator_vertical_padding_px
385 + INDICATOR_TO_LABEL_PADDING.to_px();
386
387 result.place_child(
388 indicator_background,
389 PxPosition::new(indicator_x, Px(indicator_y.0 + offset.0)),
390 );
391 result.place_child(
392 indicator_ripple,
393 PxPosition::new(ripple_x, Px(ripple_y.0 + offset.0)),
394 );
395
396 if let Some(icon_id) = icon_id {
397 result.place_child(icon_id, PxPosition::new(icon_x, Px(icon_y.0 + offset.0)));
398 }
399
400 if (self.always_show_label || self.selection_fraction != 0.0)
401 && let Some(label_id) = label_id
402 {
403 result.place_child(label_id, PxPosition::new(label_x, Px(label_y.0 + offset.0)));
404 }
405
406 Ok(result.with_size(ComputedData { width, height }))
407 }
408}
409
410#[tessera]
411fn navigation_bar_item_view(
412 item: Option<NavigationBarItemDefinition>,
413 selected: Option<bool>,
414 selection_fraction: Option<f32>,
415 on_click: Option<Callback>,
416) {
417 let item = item.unwrap_or_default();
418 let selected = selected.unwrap_or(false);
419 let selection_fraction = selection_fraction.unwrap_or(0.0);
420 let on_click = on_click.unwrap_or_default();
421 let interaction_state = remember(InteractionState::new);
422 let ripple_state = remember(RippleState::new);
423 let tap_recognizer = remember(TapRecognizer::default);
424 let focus = remember(FocusRequester::new).get();
425
426 let label = item.label.clone();
427
428 let on_press = move |ctx: PointerEventContext| {
429 let spec = RippleSpec {
430 bounded: true,
431 radius: None,
432 };
433 ripple_state.with_mut(|state| {
434 state.start_animation_with_spec(
435 ctx.normalized_pos,
436 PxSize::new(INDICATOR_WIDTH.to_px(), INDICATOR_HEIGHT.to_px()),
437 spec,
438 );
439 });
440 };
441 let on_release = move |_ctx: PointerEventContext| {
442 ripple_state.with_mut(|state| state.release());
443 };
444
445 let selectable_args = SelectableArgs {
446 selected,
447 on_click,
448 enabled: true,
449 role: Some(Role::Tab),
450 label: Some(label),
451 description: item.accessibility_description.clone(),
452 interaction_state: Some(interaction_state),
453 focus_requester: Some(focus),
454 on_press: Some(on_press.into()),
455 on_release: Some(on_release.into()),
456 tap_recognizer: Some(tap_recognizer),
457 ..Default::default()
458 };
459
460 let modifier = Modifier::new().selectable_with(selectable_args);
461 layout().modifier(modifier).child({
462 let item = item.clone();
463 move || {
464 navigation_bar_item_view_content()
465 .item(item.clone())
466 .selection_fraction(selection_fraction)
467 .interaction_state(interaction_state)
468 .ripple_state(ripple_state)
469 .focus_requester(focus);
470 }
471 });
472}
473
474#[derive(Clone, PartialEq, Default)]
475struct NavigationBarItemDefinition {
476 label: String,
477 icon: Option<RenderSlot>,
478 accessibility_description: Option<String>,
479 label_behavior: NavigationBarLabelBehavior,
480 icon_position: NavigationBarIconPosition,
481}
482
483#[tessera]
521pub fn navigation_bar_item(
522 #[prop(into)] label: Option<String>,
523 icon: Option<RenderSlot>,
524 on_click: Option<Callback>,
525 label_behavior: Option<NavigationBarLabelBehavior>,
526 selected: Option<bool>,
527 icon_position: Option<NavigationBarIconPosition>,
528 #[prop(into)] accessibility_description: Option<String>,
529) {
530 let icon_position = icon_position
531 .filter(|position| *position != NavigationBarIconPosition::Auto)
532 .unwrap_or(NavigationBarIconPosition::Top);
533 let label = label.unwrap_or_default();
534 let on_click = on_click.unwrap_or_default();
535 let label_behavior = label_behavior.unwrap_or_default();
536 let selected = selected.unwrap_or(false);
537
538 let animation = remember(|| SelectionAnimation::new(selected));
542 let frame_nanos = current_frame_nanos();
543 animation.with_mut(|state| state.set_selected(selected));
544 let selection_fraction = animation.with_mut(|state| {
545 state.update(frame_nanos);
546 state.progress()
547 });
548 if animation.with(|state| state.is_animating()) {
549 receive_frame_nanos(move |frame_nanos| {
550 let is_animating = animation.with_mut(|state| {
551 state.update(frame_nanos);
552 state.is_animating()
553 });
554 if is_animating {
555 tessera_ui::FrameNanosControl::Continue
556 } else {
557 tessera_ui::FrameNanosControl::Stop
558 }
559 });
560 }
561
562 layout()
563 .modifier(Modifier::new().weight(1.0))
564 .child(move || {
565 let item = NavigationBarItemDefinition {
566 label: label.clone(),
567 icon,
568 accessibility_description: accessibility_description.clone(),
569 label_behavior,
570 icon_position,
571 };
572 navigation_bar_item_view()
573 .item(item)
574 .selected(selected)
575 .selection_fraction(selection_fraction)
576 .on_click_shared(on_click);
577 });
578}
579
580#[tessera]
614pub fn navigation_bar(
615 content: Option<tessera_ui::RenderSlotWith<NavigationBarIconPosition>>,
616 icon_position: Option<NavigationBarIconPosition>,
617) {
618 let icon_position = icon_position.unwrap_or_default();
619 let wide = remember(|| false);
620 let resolved_position = if icon_position == NavigationBarIconPosition::Auto {
621 if wide.get() {
622 NavigationBarIconPosition::Start
623 } else {
624 NavigationBarIconPosition::Top
625 }
626 } else {
627 icon_position
628 };
629 let scheme = use_context::<MaterialTheme>()
630 .expect("MaterialTheme must be provided")
631 .get()
632 .color_scheme;
633
634 let modifier = Modifier::new()
635 .on_layout_coordinates(move |coordinates: tessera_ui::LayoutCoordinates| {
636 let value = coordinates.local_size.width.to_f32() >= Dp(600.0).to_pixels_f32();
637 if value != wide.get() {
638 wide.set(value);
639 }
640 })
641 .focus_group()
642 .focus_traversal_policy(FocusTraversalPolicy::horizontal().wrap(true));
643 layout().modifier(modifier).child({
644 move || {
645 surface()
646 .modifier(
647 Modifier::new()
648 .fill_max_width()
649 .default_min_size(Dp::ZERO, CONTAINER_HEIGHT),
650 )
651 .style(scheme.surface_container.into())
652 .block_input(true)
653 .child(move || {
654 layout()
655 .policy(NavigationBarLayout {
656 centered: resolved_position == NavigationBarIconPosition::Start,
657 })
658 .child(move || {
659 if let Some(content) = content {
660 content.render(resolved_position);
661 }
662 });
663 });
664 }
665 });
666}
667
668#[derive(Clone, PartialEq)]
669struct NavigationBarLayout {
670 centered: bool,
671}
672
673impl tessera_ui::LayoutModifierNode for NavigationBarLayout {
674 fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
675 let children = input.children();
676 let width = input
677 .parent_constraint()
678 .width()
679 .resolve_max()
680 .unwrap_or(Px::ZERO);
681 let minimum_height = CONTAINER_HEIGHT.to_px();
682 let mut height = input.parent_constraint().height().clamp(minimum_height);
683 let count = children.len();
684 let padding = if self.centered && count <= 6 {
685 Px((width.to_f32() * ((100.0 - 10.0 * (count as f32 + 3.0)) / 200.0)).round() as i32)
686 } else {
687 Px::ZERO
688 };
689 let available = width - padding * 2;
690 let mut result = LayoutResult::default();
691 let mut measured = Vec::with_capacity(count);
692 let maximum_height = input.parent_constraint().height().resolve_max();
693 for (index, child) in children.into_iter().enumerate() {
694 let start = Px(available.0 * index as i32 / count as i32);
695 let end = Px(available.0 * (index + 1) as i32 / count as i32);
696 let size = child.measure(
697 input,
698 &Constraint::new(
699 AxisConstraint::exact(end - start),
700 AxisConstraint::new(minimum_height, maximum_height),
701 ),
702 )?;
703 height = height.max(size.height);
704 measured.push((child, start, end, size.height));
705 }
706 for (child, start, end, child_height) in measured {
707 if child_height != height {
708 child.measure(
709 input,
710 &Constraint::new(
711 AxisConstraint::exact(end - start),
712 AxisConstraint::exact(height),
713 ),
714 )?;
715 }
716 result.place_child(child, PxPosition::new(padding + start, Px::ZERO));
717 }
718 Ok(result.with_size(ComputedData { width, height }))
719 }
720}
721
722#[cfg(test)]
723mod tests {
724 use tessera_ui::{
725 CursorEventContent, Dp, GestureState, Modifier, PointerChange, PressKeyEventType, Px,
726 PxPosition, remember, tessera,
727 };
728
729 use crate::{
730 column::column,
731 lazy_list::lazy_column,
732 modifier::{ModifierExt as _, SemanticsArgs},
733 spacer::spacer,
734 theme::{MaterialTheme, material_theme},
735 };
736
737 use super::{navigation_bar, navigation_bar_item};
738
739 #[tessera]
740 fn navigation_bar_row() {
741 let selected = remember(|| 0usize);
742 column().children(move || {
743 navigation_bar().content(move |position| {
744 navigation_bar_item()
745 .icon_position(position)
746 .label("Home")
747 .selected(selected.get() == 0)
748 .on_click(move || selected.set(0));
749 navigation_bar_item()
750 .icon_position(position)
751 .label("Search")
752 .selected(selected.get() == 1)
753 .on_click(move || selected.set(1));
754 navigation_bar_item()
755 .icon_position(position)
756 .label("Settings")
757 .selected(selected.get() == 2)
758 .on_click(move || selected.set(2));
759 });
760 if selected.get() == 1 {
761 spacer().modifier(Modifier::new().width(Dp(10.0)).height(Dp(10.0)).semantics(
762 SemanticsArgs {
763 test_tag: Some("nav-search-active".to_string()),
764 ..Default::default()
765 },
766 ));
767 }
768 });
769 }
770
771 #[tessera]
772 fn navigation_bar_click_probe() {
773 material_theme()
774 .theme(MaterialTheme::default)
775 .child(move || {
776 lazy_column().item(move || {
777 navigation_bar_row();
778 });
779 });
780 }
781
782 #[test]
783 fn clicking_middle_item_selects_middle_item() {
784 let mut session = tessera_ui::testing::start_layout_test_session(
785 tessera_ui::testing::layout_test(|| {
786 navigation_bar_click_probe();
787 })
788 .viewport_px(300, 200),
789 );
790 let _ = tessera_ui::testing::pump_layout_test_session(&mut session, 0);
791
792 let now = tessera_ui::time::Instant::now();
795 let cursor = Some(PxPosition::new(Px::new(150), Px::new(40)));
796
797 let moved = PointerChange::new(
801 now,
802 1,
803 CursorEventContent::Moved(PxPosition::new(Px::new(150), Px::new(40))),
804 GestureState::TapCandidate,
805 );
806 let _ = tessera_ui::testing::pump_layout_test_session_with_input(
807 &mut session,
808 16_666_667,
809 vec![moved],
810 cursor,
811 );
812
813 let press = PointerChange::new(
814 now,
815 1,
816 CursorEventContent::Pressed(PressKeyEventType::Left),
817 GestureState::TapCandidate,
818 );
819 let _ = tessera_ui::testing::pump_layout_test_session_with_input(
820 &mut session,
821 33_333_334,
822 vec![press],
823 cursor,
824 );
825 let release = PointerChange::new(
826 now,
827 1,
828 CursorEventContent::Released(PressKeyEventType::Left),
829 GestureState::TapCandidate,
830 );
831 let mut snapshot = tessera_ui::testing::pump_layout_test_session_with_input(
832 &mut session,
833 50_000_001,
834 vec![release],
835 cursor,
836 );
837 for frame in 3..40u64 {
838 snapshot = tessera_ui::testing::pump_layout_test_session(
839 &mut session,
840 50_000_001 + 16_666_667 * (frame - 2),
841 );
842 }
843
844 snapshot.node("nav-search-active").exists();
845 }
846}