1use std::time::Duration;
7
8use tessera_foundation::gesture::TapRecognizer;
9use tessera_ui::{
10 AxisConstraint, Callback, Color, ComputedData, Constraint, Dp, FocusProperties, FocusRequester,
11 FocusScopeNode, FocusTraversalPolicy, LayoutResult, MeasurementError, Modifier, Px, PxPosition,
12 PxSize, RenderSlot, State,
13 accesskit::Role,
14 current_frame_nanos,
15 layout::{MeasureScope, layout},
16 modifier::FocusModifierExt as _,
17 provide_context, receive_frame_nanos, remember, tessera, use_context,
18};
19
20use crate::{
21 alignment::{CrossAxisAlignment, MainAxisAlignment},
22 animation::{
23 self, SelectionAnimation,
24 visibility_motion::{VisibilityMotion, modal_back_handler},
25 },
26 badge::BadgeDefaults,
27 column::column,
28 modal_focus::{ModalFocusState, modal_focus_modifier},
29 modifier::{
30 InteractionState, ModifierExt, Padding, PointerEventContext, SelectableArgs, SemanticsArgs,
31 with_keyboard_input,
32 },
33 navigation_bar::interpolate_color,
34 overlay_semantics::{dialog_semantics, overlay_background},
35 ripple_state::{RippleSpec, RippleState},
36 row::row,
37 shape_def::{RoundedCorner, Shape},
38 surface::{SurfaceStyle, surface},
39 text::text,
40 theme::{ContentColor, MaterialTheme},
41};
42
43#[cfg(test)]
44#[path = "navigation_destination_tests.rs"]
45mod destination_tests;
46
47const ANIMATION_DURATION: Duration = Duration::from_millis(300);
48const COLLAPSED_WIDTH: Dp = Dp(96.0);
49const EXPANDED_MIN_WIDTH: Dp = Dp(220.0);
50const TOP_PADDING: Dp = Dp(36.0);
51const HEADER_BOTTOM_PADDING: Dp = Dp(40.0);
52const ITEM_HORIZONTAL_PADDING: Dp = Dp(20.0);
53const ITEM_VERTICAL_SPACING_COLLAPSED: Dp = Dp(4.0);
54const ITEM_VERTICAL_SPACING_EXPANDED: Dp = Dp(0.0);
55const TOP_ICON_ITEM_MIN_HEIGHT: Dp = Dp(56.0);
56const START_ICON_ITEM_MIN_HEIGHT: Dp = Dp(56.0);
57const INDICATOR_TOP_WIDTH: Dp = Dp(56.0);
58const INDICATOR_TOP_HEIGHT: Dp = Dp(32.0);
59const INDICATOR_START_HEIGHT: Dp = Dp(56.0);
60const INDICATOR_TOP_TO_LABEL_PADDING: Dp = Dp(4.0);
61const START_ICON_TO_LABEL_PADDING: Dp = Dp(8.0);
62const TOP_ICON_INDICATOR_VERTICAL_PADDING: Dp = Dp(4.0);
63const START_ICON_INDICATOR_HORIZONTAL_PADDING: Dp = Dp(16.0);
64
65fn lerp_dp(from: Dp, to: Dp, progress: f32) -> Dp {
66 let t = f64::from(progress.clamp(0.0, 1.0));
67 Dp(from.0 + (to.0 - from.0) * t)
68}
69
70#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
71enum NavigationRailIconPosition {
72 #[default]
73 Top,
74 Start,
75}
76
77#[derive(Clone, Copy, PartialEq)]
79pub struct NavigationRailScope {
80 icon_position: NavigationRailIconPosition,
81 indicator_start_width: Dp,
82 item_min_height: Dp,
83 item_spacing: Dp,
84}
85
86#[tessera]
87fn navigation_rail_item_view_content(
88 item: Option<NavigationRailItemDefinition>,
89 icon_position: Option<NavigationRailIconPosition>,
90 selection_fraction: Option<f32>,
91 item_min_height: Option<Dp>,
92 interaction_state: Option<State<InteractionState>>,
93 ripple_state: Option<State<RippleState>>,
94 indicator_geometry: Option<State<RailRippleGeometry>>,
95 focus_requester: Option<FocusRequester>,
96) {
97 let item = item.unwrap_or_default();
98 let icon_position = icon_position.unwrap_or_default();
99 let selection_fraction = selection_fraction.unwrap_or(0.0);
100 let item_min_height = item_min_height.unwrap_or(Dp(0.0));
101 let interaction_state = interaction_state.expect("interaction_state must be set");
102 let ripple_state = ripple_state.expect("ripple_state must be set");
103 let focus_requester = focus_requester.expect("focus_requester must be set");
104 let theme = use_context::<MaterialTheme>()
105 .expect("MaterialTheme must be provided")
106 .get();
107 let scheme = theme.color_scheme;
108 let typography = theme.typography;
109
110 let has_label = !item.label.is_empty();
111 let has_icon = item.icon.is_some();
112 let has_badge = item.badge.is_some();
113
114 let icon_color = interpolate_color(
115 scheme.on_surface_variant,
116 scheme.on_secondary_container,
117 selection_fraction,
118 );
119 let label_color = interpolate_color(
120 scheme.on_surface_variant,
121 if icon_position == NavigationRailIconPosition::Start {
122 scheme.on_secondary_container
123 } else {
124 scheme.secondary
125 },
126 selection_fraction,
127 );
128 let ripple_color = icon_color;
129 let indicator_color = scheme.secondary_container.with_alpha(selection_fraction);
130
131 layout()
132 .policy(NavigationRailItemLayout {
133 icon_position,
134 has_label,
135 has_icon,
136 has_badge,
137 selection_fraction,
138 item_min_height,
139 })
140 .child(move || {
141 surface()
142 .style(SurfaceStyle::Filled {
143 color: indicator_color,
144 })
145 .shape(Shape::CAPSULE)
146 .show_state_layer(false)
147 .show_ripple(false)
148 .child(|| {});
149
150 surface()
151 .style(SurfaceStyle::Filled {
152 color: Color::TRANSPARENT,
153 })
154 .shape(Shape::CAPSULE)
155 .modifier(Modifier::new().on_layout_coordinates(
156 move |bounds: tessera_ui::LayoutCoordinates| {
157 if let Some(indicator_geometry) = indicator_geometry {
158 if indicator_geometry.with(|geometry| geometry.indicator)
159 != Some(bounds)
160 {
161 indicator_geometry
162 .with_mut(|geometry| geometry.indicator = Some(bounds));
163 }
164 }
165 },
166 ))
167 .enabled(true)
168 .interaction_state(interaction_state)
169 .focus_requester(focus_requester)
170 .show_focus_indicator(true)
171 .ripple_color(ripple_color)
172 .child(move || {
173 surface()
174 .style(SurfaceStyle::Filled {
175 color: Color::TRANSPARENT,
176 })
177 .shape(Shape::CAPSULE)
178 .modifier(Modifier::new().fill_max_size())
179 .enabled(true)
180 .ripple_color(ripple_color)
181 .show_state_layer(false)
182 .ripple_state(ripple_state)
183 .child(|| {});
184 });
185
186 if let Some(draw_icon) = item.icon {
187 provide_context(
188 || ContentColor {
189 current: icon_color,
190 },
191 || {
192 let color = use_context::<ContentColor>().unwrap();
193 if color.with(|value| value.current) != icon_color {
194 color.set(ContentColor {
195 current: icon_color,
196 });
197 }
198 draw_icon.render();
199 },
200 );
201 }
202
203 if has_label {
204 let label = item.label.clone();
205 let label_style = match icon_position {
206 NavigationRailIconPosition::Top => typography.label_medium,
207 NavigationRailIconPosition::Start => typography.label_large,
208 };
209 provide_context(
210 || label_style,
211 move || {
212 text(label.clone()).color(label_color).style(label_style);
213 },
214 );
215 }
216 if let Some(badge) = item.badge {
217 crate::boxed::boxed().children_shared(badge);
218 }
219 });
220}
221
222#[derive(Clone, PartialEq)]
223struct NavigationRailItemLayout {
224 icon_position: NavigationRailIconPosition,
225 has_label: bool,
226 has_icon: bool,
227 has_badge: bool,
228 selection_fraction: f32,
229 item_min_height: Dp,
230}
231
232impl tessera_ui::LayoutModifierNode for NavigationRailItemLayout {
233 fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
234 let mut result = LayoutResult::default();
235 let children = input.children();
236 let parent_width = input
237 .parent_constraint()
238 .width()
239 .resolve_max()
240 .unwrap_or(Px::ZERO);
241 let min_height = self.item_min_height.to_px();
242
243 let indicator_background = children[0];
244 let indicator_ripple = children[1];
245 let mut child_index = 2;
246
247 let icon_id = if self.has_icon {
248 let id = children[child_index];
249 child_index += 1;
250 Some(id)
251 } else {
252 None
253 };
254
255 let label_id = if self.has_label {
256 let id = children[child_index];
257 child_index += 1;
258 Some(id)
259 } else {
260 None
261 };
262
263 let child_constraint = Constraint::NONE;
264 let icon_size = icon_id
265 .map(|id| id.measure(input, &child_constraint))
266 .transpose()?
267 .map(|size| size.size())
268 .unwrap_or(ComputedData::ZERO);
269 let badge_id = self.has_badge.then(|| children[child_index]);
270 let badge_size = badge_id
271 .map(|id| id.measure(input, &child_constraint))
272 .transpose()?
273 .map(|size| size.size())
274 .unwrap_or(ComputedData::ZERO);
275 let label_size = label_id
276 .map(|id| {
277 id.measure(
278 input,
279 &Constraint::new(
280 AxisConstraint::new(
281 Px::ZERO,
282 Some(
283 (parent_width
284 - if self.icon_position == NavigationRailIconPosition::Start {
285 ITEM_HORIZONTAL_PADDING.to_px() * 2
286 + START_ICON_INDICATOR_HORIZONTAL_PADDING.to_px() * 2
287 + icon_size.width
288 + if self.has_icon {
289 START_ICON_TO_LABEL_PADDING.to_px()
290 } else {
291 Px::ZERO
292 }
293 + badge_size.width
294 + if self.has_badge {
295 Dp(8.0).to_px()
296 } else {
297 Px::ZERO
298 }
299 } else {
300 Dp(8.0).to_px()
301 })
302 .max(Px::ZERO),
303 ),
304 ),
305 AxisConstraint::NONE,
306 ),
307 )
308 })
309 .transpose()?
310 .map(|size| size.size())
311 .unwrap_or(ComputedData::ZERO);
312
313 let expanded = self.icon_position == NavigationRailIconPosition::Start;
314 let label_gap = if self.has_icon && self.has_label {
315 START_ICON_TO_LABEL_PADDING.to_px()
316 } else {
317 Px::ZERO
318 };
319 let badge_gap = if self.has_badge && (self.has_icon || self.has_label) {
320 Dp(8.0).to_px()
321 } else {
322 Px::ZERO
323 };
324 let content_width =
325 icon_size.width + label_gap + label_size.width + badge_gap + badge_size.width;
326 let base_width = if expanded {
327 (content_width + START_ICON_INDICATOR_HORIZONTAL_PADDING.to_px() * 2)
328 .min((parent_width - ITEM_HORIZONTAL_PADDING.to_px() * 2).max(Px::ZERO))
329 } else {
330 INDICATOR_TOP_WIDTH.to_px()
331 };
332 let indicator_height = if expanded {
333 INDICATOR_START_HEIGHT.to_px().max(
334 icon_size
335 .height
336 .max(label_size.height)
337 .max(badge_size.height)
338 + START_ICON_INDICATOR_HORIZONTAL_PADDING.to_px() * 2,
339 )
340 } else {
341 INDICATOR_TOP_HEIGHT.to_px()
342 };
343 let animated_width =
344 Px((base_width.0 as f32 * self.selection_fraction.clamp(0.0, 1.0)).round() as i32);
345 let indicator_size = indicator_background
346 .measure(
347 input,
348 &Constraint::new(
349 AxisConstraint::exact(animated_width),
350 AxisConstraint::exact(indicator_height),
351 ),
352 )?
353 .size();
354 let indicator_ripple_size = indicator_ripple
355 .measure(
356 input,
357 &Constraint::new(
358 AxisConstraint::exact(base_width),
359 AxisConstraint::exact(indicator_height),
360 ),
361 )?
362 .size();
363 let width = parent_width;
364 let content_height = if expanded {
365 icon_size
366 .height
367 .max(label_size.height)
368 .max(badge_size.height)
369 + START_ICON_INDICATOR_HORIZONTAL_PADDING.to_px() * 2
370 } else if self.has_label {
371 icon_size.height
372 + label_size.height
373 + TOP_ICON_INDICATOR_VERTICAL_PADDING.to_px() * 3
374 + INDICATOR_TOP_TO_LABEL_PADDING.to_px()
375 } else {
376 icon_size.height.max(INDICATOR_TOP_HEIGHT.to_px())
377 };
378 let height = input
379 .parent_constraint()
380 .height()
381 .clamp(min_height.max(content_height));
382
383 match self.icon_position {
384 NavigationRailIconPosition::Start => {
385 let horizontal_padding = ITEM_HORIZONTAL_PADDING.to_px();
386 let ripple_x = horizontal_padding;
387 let ripple_y = (height - indicator_ripple_size.height) / 2;
388 result.place_child(indicator_ripple, PxPosition::new(ripple_x, ripple_y));
389
390 let indicator_x =
391 ripple_x + (indicator_ripple_size.width - indicator_size.width) / 2;
392 let indicator_y = (height - indicator_size.height) / 2;
393 result.place_child(
394 indicator_background,
395 PxPosition::new(indicator_x, indicator_y),
396 );
397
398 let content_x =
399 horizontal_padding + START_ICON_INDICATOR_HORIZONTAL_PADDING.to_px();
400
401 if let Some(icon_id) = icon_id {
402 let icon_y = (height - icon_size.height) / 2;
403 result.place_child(icon_id, PxPosition::new(content_x, icon_y));
404 }
405
406 if let Some(label_id) = label_id {
407 let label_y = (height - label_size.height) / 2;
408 let label_x = if self.has_icon {
409 content_x + icon_size.width + START_ICON_TO_LABEL_PADDING.to_px()
410 } else {
411 content_x
412 };
413 result.place_child(label_id, PxPosition::new(label_x, label_y));
414 }
415
416 if let Some(badge_id) = badge_id {
417 let badge_x =
418 content_x + icon_size.width + label_gap + label_size.width + badge_gap;
419 result.place_child(
420 badge_id,
421 PxPosition::new(badge_x, (height - badge_size.height) / 2),
422 );
423 }
424
425 return Ok(result.with_size(ComputedData { width, height }));
426 }
427 NavigationRailIconPosition::Top => {}
428 }
429
430 if !self.has_label {
431 let ripple_x = (width - indicator_ripple_size.width) / 2;
432 let ripple_y = (height - indicator_ripple_size.height) / 2;
433 let indicator_x = (width - indicator_size.width) / 2;
434 let indicator_y = (height - indicator_size.height) / 2;
435 result.place_child(
436 indicator_background,
437 PxPosition::new(indicator_x, indicator_y),
438 );
439 result.place_child(indicator_ripple, PxPosition::new(ripple_x, ripple_y));
440
441 if let Some(icon_id) = icon_id {
442 let icon_x = (width - icon_size.width) / 2;
443 let icon_y = (height - icon_size.height) / 2;
444 result.place_child(icon_id, PxPosition::new(icon_x, icon_y));
445 }
446
447 if let Some(badge_id) = badge_id {
448 let large = badge_size.width > BadgeDefaults::SIZE.to_px();
449 let dx = if large {
450 BadgeDefaults::WITH_CONTENT_HORIZONTAL_OFFSET
451 } else {
452 BadgeDefaults::OFFSET
453 }
454 .to_px();
455 let dy = if large {
456 BadgeDefaults::WITH_CONTENT_VERTICAL_OFFSET
457 } else {
458 BadgeDefaults::OFFSET
459 }
460 .to_px();
461 result.place_child(
462 badge_id,
463 PxPosition::new(
464 (width + icon_size.width) / 2 - dx,
465 (height - icon_size.height) / 2 - badge_size.height + dy,
466 ),
467 );
468 }
469
470 return Ok(result.with_size(ComputedData { width, height }));
471 }
472
473 let indicator_vertical_padding_px = TOP_ICON_INDICATOR_VERTICAL_PADDING.to_px();
474 let content_height = icon_size.height
475 + indicator_vertical_padding_px
476 + INDICATOR_TOP_TO_LABEL_PADDING.to_px()
477 + label_size.height;
478 let content_vertical_padding =
479 ((height - content_height) / 2).max(indicator_vertical_padding_px);
480
481 let icon_x = (width - icon_size.width) / 2;
482 let label_x = (width - label_size.width) / 2;
483 let indicator_x = (width - indicator_size.width) / 2;
484 let ripple_x = (width - indicator_ripple_size.width) / 2;
485
486 let indicator_y = content_vertical_padding - indicator_vertical_padding_px;
487 let icon_y = content_vertical_padding;
488 let ripple_y = indicator_y;
489 let label_y = content_vertical_padding
490 + icon_size.height
491 + indicator_vertical_padding_px
492 + INDICATOR_TOP_TO_LABEL_PADDING.to_px();
493
494 result.place_child(
495 indicator_background,
496 PxPosition::new(indicator_x, indicator_y),
497 );
498 result.place_child(indicator_ripple, PxPosition::new(ripple_x, ripple_y));
499
500 if let Some(icon_id) = icon_id {
501 result.place_child(icon_id, PxPosition::new(icon_x, icon_y));
502 }
503
504 if let Some(label_id) = label_id {
505 result.place_child(label_id, PxPosition::new(label_x, label_y));
506 }
507
508 if let Some(badge_id) = badge_id {
509 let large = badge_size.width > BadgeDefaults::SIZE.to_px();
510 let dx = if large {
511 BadgeDefaults::WITH_CONTENT_HORIZONTAL_OFFSET
512 } else {
513 BadgeDefaults::OFFSET
514 }
515 .to_px();
516 let dy = if large {
517 BadgeDefaults::WITH_CONTENT_VERTICAL_OFFSET
518 } else {
519 BadgeDefaults::OFFSET
520 }
521 .to_px();
522 result.place_child(
523 badge_id,
524 PxPosition::new(
525 icon_x + icon_size.width - dx,
526 icon_y - badge_size.height + dy,
527 ),
528 );
529 }
530
531 Ok(result.with_size(ComputedData { width, height }))
532 }
533}
534
535#[tessera]
536fn navigation_rail_item_view(
537 item: Option<NavigationRailItemDefinition>,
538 selected: Option<bool>,
539 selection_fraction: Option<f32>,
540 icon_position: Option<NavigationRailIconPosition>,
541 indicator_start_width: Option<Dp>,
542 item_min_height: Option<Dp>,
543) {
544 let item = item.unwrap_or_default();
545 let selected = selected.unwrap_or(false);
546 let selection_fraction = selection_fraction.unwrap_or(0.0);
547 let icon_position = icon_position.unwrap_or_default();
548 let indicator_start_width = indicator_start_width.unwrap_or(Dp(0.0));
549 let item_min_height = item_min_height.unwrap_or(Dp(0.0));
550 let interaction_state = remember(InteractionState::new);
551 let ripple_state = remember(RippleState::new);
552 let tap_recognizer = remember(TapRecognizer::default);
553 let focus = remember(FocusRequester::new).get();
554
555 let label = item.label.clone();
556 let description = item.accessibility_description.clone();
557 let on_click = item.on_click;
558 let ripple_size = match icon_position {
559 NavigationRailIconPosition::Top => {
560 PxSize::new(INDICATOR_TOP_WIDTH.to_px(), INDICATOR_TOP_HEIGHT.to_px())
561 }
562 NavigationRailIconPosition::Start => PxSize::new(
563 indicator_start_width.to_px(),
564 INDICATOR_START_HEIGHT.to_px(),
565 ),
566 };
567
568 let indicator_geometry = remember(RailRippleGeometry::default);
569 let on_press = {
570 move |ctx: PointerEventContext| {
571 let spec = RippleSpec {
572 bounded: true,
573 radius: None,
574 };
575 ripple_state.with_mut(|state| {
576 let geometry = indicator_geometry.get();
577 let (position, size) = if let (Some(target), Some(indicator)) =
578 (geometry.target, geometry.indicator)
579 {
580 let point = target.local_to_window.map_point(PxPosition::from_f32_xy(
581 ctx.normalized_pos[0] * ctx.size.width.to_f32(),
582 ctx.normalized_pos[1] * ctx.size.height.to_f32(),
583 ));
584 let point = indicator.local_to_window.inverse_point(point);
585 (
586 [
587 point.x / indicator.local_size.width.to_f32().max(1.0),
588 point.y / indicator.local_size.height.to_f32().max(1.0),
589 ],
590 indicator.local_size,
591 )
592 } else {
593 (ctx.normalized_pos, ripple_size)
594 };
595 state.start_animation_with_spec(position, size, spec);
596 });
597 }
598 };
599 let on_release = {
600 move |_ctx: PointerEventContext| {
601 ripple_state.with_mut(|state| state.release());
602 }
603 };
604
605 let selectable_args = SelectableArgs {
606 selected,
607 on_click,
608 enabled: true,
609 role: Some(Role::Tab),
610 label: Some(label),
611 description,
612 interaction_state: Some(interaction_state),
613 focus_requester: Some(focus),
614 on_press: Some(on_press.into()),
615 on_release: Some(on_release.into()),
616 tap_recognizer: Some(tap_recognizer),
617 ..Default::default()
618 };
619
620 let modifier = Modifier::new()
621 .selectable_with(selectable_args)
622 .on_layout_coordinates(move |bounds: tessera_ui::LayoutCoordinates| {
623 if indicator_geometry.with(|geometry| geometry.target) != Some(bounds) {
624 indicator_geometry.with_mut(|geometry| geometry.target = Some(bounds));
625 }
626 });
627 layout().modifier(modifier).child({
628 let item = item.clone();
629 move || {
630 navigation_rail_item_view_content()
631 .item(item.clone())
632 .icon_position(icon_position)
633 .selection_fraction(selection_fraction)
634 .item_min_height(item_min_height)
635 .interaction_state(interaction_state)
636 .ripple_state(ripple_state)
637 .indicator_geometry(indicator_geometry)
638 .focus_requester(focus);
639 }
640 });
641}
642
643#[derive(Clone, Copy, Default, PartialEq)]
644struct RailRippleGeometry {
645 target: Option<tessera_ui::LayoutCoordinates>,
646 indicator: Option<tessera_ui::LayoutCoordinates>,
647}
648
649#[derive(Clone, PartialEq, Default)]
650struct NavigationRailItemDefinition {
651 label: String,
652 icon: Option<RenderSlot>,
653 badge: Option<RenderSlot>,
654 accessibility_description: Option<String>,
655 on_click: Callback,
656}
657
658#[tessera]
696pub fn navigation_rail_item(
697 rail: NavigationRailScope,
698 #[prop(into)] label: Option<String>,
699 icon: Option<RenderSlot>,
700 on_click: Option<Callback>,
701 selected: Option<bool>,
702 badge: Option<RenderSlot>,
703 #[prop(into)] accessibility_description: Option<String>,
704) {
705 let label = label.unwrap_or_default();
706 let on_click = on_click.unwrap_or_default();
707 let selected = selected.unwrap_or(false);
708 let composition = rail;
709
710 let animation = remember(|| SelectionAnimation::new(selected));
713 let frame_nanos = current_frame_nanos();
714 animation.with_mut(|state| state.set_selected(selected));
715 let selection_fraction = animation.with_mut(|state| {
716 state.update(frame_nanos);
717 state.progress()
718 });
719 if animation.with(|state| state.is_animating()) {
720 receive_frame_nanos(move |frame_nanos| {
721 let is_animating = animation.with_mut(|state| {
722 state.update(frame_nanos);
723 state.is_animating()
724 });
725 if is_animating {
726 tessera_ui::FrameNanosControl::Continue
727 } else {
728 tessera_ui::FrameNanosControl::Stop
729 }
730 });
731 }
732
733 let item_spacing = composition.item_spacing;
734 layout()
735 .modifier(Modifier::new().padding(Padding::new(Dp::ZERO, Dp::ZERO, Dp::ZERO, item_spacing)))
736 .child(move || {
737 let item = NavigationRailItemDefinition {
738 label: label.clone(),
739 icon,
740 badge,
741 accessibility_description: accessibility_description.clone(),
742 on_click,
743 };
744 navigation_rail_item_view()
745 .item(item)
746 .selected(selected)
747 .selection_fraction(selection_fraction)
748 .icon_position(composition.icon_position)
749 .indicator_start_width(composition.indicator_start_width)
750 .item_min_height(composition.item_min_height);
751 });
752}
753
754#[tessera]
787pub fn navigation_rail(
788 expanded: Option<bool>,
789 content: Option<tessera_ui::RenderSlotWith<NavigationRailScope>>,
790 header: Option<RenderSlot>,
791) {
792 let expanded = expanded.unwrap_or(false);
793 let scheme = use_context::<MaterialTheme>()
794 .expect("MaterialTheme must be provided")
795 .get()
796 .color_scheme;
797 let controller = remember(NavigationRailExpansion::default);
798 let frame_nanos = current_frame_nanos();
799 controller.with_mut(|c| c.set_expanded(expanded));
800 let expand_fraction = controller.with_mut(|c| {
801 c.update(frame_nanos);
802 c.expand_fraction()
803 });
804 if controller.with(|c| c.is_animating()) {
805 receive_frame_nanos(move |frame_nanos| {
806 let is_animating = controller.with_mut(|c| {
807 c.update(frame_nanos);
808 c.is_animating()
809 });
810 if is_animating {
811 tessera_ui::FrameNanosControl::Continue
812 } else {
813 tessera_ui::FrameNanosControl::Stop
814 }
815 });
816 }
817
818 let icon_position = if expand_fraction >= 0.5 {
819 NavigationRailIconPosition::Start
820 } else {
821 NavigationRailIconPosition::Top
822 };
823
824 let container_width = lerp_dp(COLLAPSED_WIDTH, EXPANDED_MIN_WIDTH, expand_fraction);
825 let item_min_height = match icon_position {
826 NavigationRailIconPosition::Top => TOP_ICON_ITEM_MIN_HEIGHT,
827 NavigationRailIconPosition::Start => START_ICON_ITEM_MIN_HEIGHT,
828 };
829 let item_spacing = match icon_position {
830 NavigationRailIconPosition::Top => ITEM_VERTICAL_SPACING_COLLAPSED,
831 NavigationRailIconPosition::Start => ITEM_VERTICAL_SPACING_EXPANDED,
832 };
833 let indicator_start_width =
834 Dp((container_width.0 - ITEM_HORIZONTAL_PADDING.0 * 2.0).max(INDICATOR_TOP_WIDTH.0));
835 let composition = NavigationRailScope {
836 icon_position,
837 indicator_start_width,
838 item_min_height,
839 item_spacing,
840 };
841
842 let modifier = Modifier::new()
843 .focus_group()
844 .focus_traversal_policy(FocusTraversalPolicy::vertical().wrap(true));
845 layout().modifier(modifier).child({
846 let composition = composition;
847 move || {
848 let composition = composition;
849 surface()
850 .modifier(Modifier::new().fill_max_height().width(container_width))
851 .style(scheme.surface.into())
852 .block_input(true)
853 .child(move || {
854 navigation_rail_content()
855 .composition(composition)
856 .header_optional(header)
857 .content_optional(content);
858 });
859 }
860 });
861}
862
863#[tessera]
864fn navigation_rail_content(
865 composition: Option<NavigationRailScope>,
866 header: Option<RenderSlot>,
867 content: Option<tessera_ui::RenderSlotWith<NavigationRailScope>>,
868) {
869 let content_context = composition.expect("rail content requires item composition");
870 column()
871 .modifier(Modifier::new().fill_max_size().padding(Padding::new(
872 Dp::ZERO,
873 TOP_PADDING,
874 Dp::ZERO,
875 Dp::ZERO,
876 )))
877 .main_axis_alignment(MainAxisAlignment::Start)
878 .cross_axis_alignment(CrossAxisAlignment::Start)
879 .children(move || {
880 if let Some(header) = header {
881 row()
882 .modifier(Modifier::new().padding(Padding::new(
883 ITEM_HORIZONTAL_PADDING,
884 Dp::ZERO,
885 Dp::ZERO,
886 Dp::ZERO,
887 )))
888 .children(move || {
889 header.render();
890 });
891 layout().modifier(Modifier::new().height(HEADER_BOTTOM_PADDING));
892 }
893
894 if let Some(content) = content {
895 content.render(content_context);
896 }
897 });
898}
899
900#[tessera]
959pub fn modal_navigation_rail(
960 is_open: Option<bool>,
961 on_close_request: Option<Callback>,
962 #[prop(into)] accessibility_label: Option<String>,
963 main_content: Option<RenderSlot>,
964 header: Option<RenderSlot>,
965 content: Option<tessera_ui::RenderSlotWith<NavigationRailScope>>,
966) {
967 let is_open = is_open.unwrap_or(false);
968 let on_close_request = on_close_request.unwrap_or_default();
969 let main_content = main_content.unwrap_or_default();
970 let scheme = use_context::<MaterialTheme>()
971 .expect("MaterialTheme must be provided")
972 .get()
973 .color_scheme;
974 let scope = remember(FocusScopeNode::new).get();
975 let focus = remember(ModalFocusState::default);
976 let was_open = remember(|| false);
977 let back_motion = remember(|| VisibilityMotion::new(true, true));
978 let just_opened = is_open && !was_open.get();
979 if was_open.get() != is_open {
980 was_open.set(is_open);
981 back_motion.set(VisibilityMotion::new(true, true));
982 }
983 let back = back_motion.get().sample(current_frame_nanos());
984 if back.active {
985 receive_frame_nanos(move |frame| {
986 if back_motion.with(|motion| motion.sample(frame).active) {
987 tessera_ui::FrameNanosControl::Continue
988 } else {
989 tessera_ui::FrameNanosControl::Stop
990 }
991 });
992 }
993 let modifier = modal_focus_modifier(Modifier::new(), focus, is_open, scope);
994 layout()
995 .modifier(modifier)
996 .policy(ModalNavigationRailLayout)
997 .child(move || {
998 overlay_background(main_content, is_open);
999 modal_back_handler(is_open, on_close_request).on_preview(move |event| {
1000 back_motion.with_mut(|motion| motion.preview_back(event, current_frame_nanos()));
1001 });
1002 if is_open {
1003 surface()
1004 .style(scheme.scrim.with_alpha(0.32 * back.opacity).into())
1005 .modifier(
1006 Modifier::new()
1007 .fill_max_size()
1008 .semantics(SemanticsArgs {
1009 hidden: true,
1010 ..Default::default()
1011 })
1012 .focusable()
1013 .focus_properties(
1014 FocusProperties::new()
1015 .can_focus(false)
1016 .can_request_focus(false)
1017 .skip_traversal(true),
1018 ),
1019 )
1020 .on_click_shared(on_close_request)
1021 .show_ripple(false)
1022 .show_state_layer(false)
1023 .block_input(true)
1024 .child(|| {});
1025 let modifier = dialog_semantics(
1026 with_keyboard_input(
1027 Modifier::new()
1028 .focus_scope_with(scope)
1029 .focus_properties(FocusProperties::new().skip_traversal(true))
1030 .focus_traversal_policy(
1031 FocusTraversalPolicy::linear()
1032 .wrap(true)
1033 .tab_navigation(true),
1034 ),
1035 Box::new(move |input: tessera_ui::KeyboardInput<'_>| {
1036 input.keyboard_events.retain(|event| {
1037 !modal_rail_key(event.physical_key, event.state, on_close_request)
1038 });
1039 }),
1040 ),
1041 accessibility_label.clone(),
1042 true,
1043 );
1044 if just_opened {
1045 scope.request_focus();
1046 }
1047 surface()
1048 .modifier(
1049 modifier
1050 .fill_max_size()
1051 .offset(Dp(-48.0 * f64::from(1.0 - back.position)), Dp::ZERO)
1052 .alpha(back.opacity),
1053 )
1054 .style(scheme.surface_container_low.into())
1055 .elevation(Dp(1.0))
1056 .shape(Shape::RoundedRectangle {
1057 top_left: RoundedCorner::ZERO,
1058 top_right: RoundedCorner::manual(Dp(16.0), 2.0),
1059 bottom_right: RoundedCorner::manual(Dp(16.0), 2.0),
1060 bottom_left: RoundedCorner::ZERO,
1061 })
1062 .block_input(true)
1063 .child(move || {
1064 navigation_rail_content()
1065 .composition(NavigationRailScope {
1066 icon_position: NavigationRailIconPosition::Start,
1067 indicator_start_width: Dp(
1068 EXPANDED_MIN_WIDTH.0 - ITEM_HORIZONTAL_PADDING.0 * 2.0
1069 ),
1070 item_min_height: START_ICON_ITEM_MIN_HEIGHT,
1071 item_spacing: ITEM_VERTICAL_SPACING_EXPANDED,
1072 })
1073 .header_optional(header)
1074 .content_optional(content);
1075 });
1076 }
1077 });
1078}
1079
1080fn modal_rail_key(
1081 key: tessera_ui::winit::keyboard::PhysicalKey,
1082 state: tessera_ui::winit::event::ElementState,
1083 on_close: Callback,
1084) -> bool {
1085 if state == tessera_ui::winit::event::ElementState::Pressed
1086 && key
1087 == tessera_ui::winit::keyboard::PhysicalKey::Code(
1088 tessera_ui::winit::keyboard::KeyCode::Escape,
1089 )
1090 {
1091 on_close.call();
1092 true
1093 } else {
1094 false
1095 }
1096}
1097
1098#[derive(Clone, PartialEq)]
1099struct ModalNavigationRailLayout;
1100
1101impl tessera_ui::LayoutModifierNode for ModalNavigationRailLayout {
1102 fn measure(&self, scope: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
1103 let children = scope.children();
1104 let main = children[0];
1105 let constraint = scope.parent_constraint().without_min();
1106 let main_size = main.measure(scope, &constraint)?;
1107 let size = ComputedData {
1108 width: scope.parent_constraint().width().clamp(main_size.width),
1109 height: scope.parent_constraint().height().clamp(main_size.height),
1110 };
1111 let mut result = LayoutResult::new(size);
1112 result.place_child(main, PxPosition::ZERO);
1113 if children.len() > 1 {
1114 children[1].measure(scope, &Constraint::exact(size.width, size.height))?;
1115 result.place_child(children[1], PxPosition::ZERO);
1116 children[2].measure(
1117 scope,
1118 &Constraint::exact(size.width.min(EXPANDED_MIN_WIDTH.to_px()), size.height),
1119 )?;
1120 result.place_child(children[2], PxPosition::ZERO);
1121 }
1122 Ok(result)
1123 }
1124}
1125
1126#[derive(Clone, PartialEq, Default)]
1128struct NavigationRailExpansion {
1129 expanded: bool,
1130 start_frame_nanos: Option<u64>,
1131}
1132
1133impl NavigationRailExpansion {
1134 fn set_expanded(&mut self, expanded: bool) {
1135 if self.expanded != expanded {
1136 self.expanded = expanded;
1137 self.start_frame_nanos = Some(current_frame_nanos());
1138 }
1139 }
1140
1141 fn update(&mut self, frame_nanos: u64) {
1142 if let Some(start_frame_nanos) = self.start_frame_nanos {
1143 let elapsed_nanos = frame_nanos.saturating_sub(start_frame_nanos);
1144 let animation_nanos = ANIMATION_DURATION.as_nanos().min(u64::MAX as u128) as u64;
1145 if elapsed_nanos >= animation_nanos {
1146 self.start_frame_nanos = None;
1147 }
1148 }
1149 }
1150
1151 fn expand_fraction(&self) -> f32 {
1152 let raw = match self.start_frame_nanos {
1153 None => 1.0,
1154 Some(start_frame_nanos) => {
1155 let elapsed_nanos = current_frame_nanos().saturating_sub(start_frame_nanos);
1156 let animation_nanos = ANIMATION_DURATION.as_nanos().min(u64::MAX as u128) as u64;
1157 if elapsed_nanos >= animation_nanos {
1158 1.0
1159 } else {
1160 elapsed_nanos as f32 / animation_nanos as f32
1161 }
1162 }
1163 };
1164 let eased = animation::easing(raw);
1165 if self.expanded { eased } else { 1.0 - eased }
1166 }
1167
1168 fn is_animating(&self) -> bool {
1169 self.start_frame_nanos.is_some()
1170 }
1171}
1172
1173#[cfg(test)]
1174mod tests {
1175 use tessera_ui::{
1176 CursorEventContent, Dp, GestureState, Modifier, PointerChange, PressKeyEventType, Px,
1177 PxPosition, remember, tessera,
1178 };
1179
1180 use crate::{
1181 column::column,
1182 lazy_list::lazy_column,
1183 modifier::{ModifierExt as _, SemanticsArgs},
1184 spacer::spacer,
1185 theme::{MaterialTheme, material_theme},
1186 };
1187
1188 use super::{navigation_rail, navigation_rail_item};
1189
1190 #[tessera]
1191 fn navigation_rail_row() {
1192 let selected = remember(|| 0usize);
1193 column().children(move || {
1194 navigation_rail().content(move |rail| {
1195 navigation_rail_item(rail)
1196 .label("Home")
1197 .selected(selected.get() == 0)
1198 .on_click(move || selected.set(0));
1199 navigation_rail_item(rail)
1200 .label("Search")
1201 .selected(selected.get() == 1)
1202 .on_click(move || selected.set(1));
1203 navigation_rail_item(rail)
1204 .label("Settings")
1205 .selected(selected.get() == 2)
1206 .on_click(move || selected.set(2));
1207 });
1208 if selected.get() == 1 {
1209 spacer().modifier(Modifier::new().width(Dp(10.0)).height(Dp(10.0)).semantics(
1210 SemanticsArgs {
1211 test_tag: Some("rail-search-active".to_string()),
1212 ..Default::default()
1213 },
1214 ));
1215 }
1216 });
1217 }
1218
1219 #[tessera]
1220 fn navigation_rail_click_probe() {
1221 material_theme()
1222 .theme(MaterialTheme::default)
1223 .child(move || {
1224 lazy_column().item(move || {
1225 navigation_rail_row();
1226 });
1227 });
1228 }
1229
1230 #[test]
1231 fn clicking_middle_item_selects_middle_item() {
1232 let mut session = tessera_ui::testing::start_layout_test_session(
1233 tessera_ui::testing::layout_test(|| {
1234 navigation_rail_click_probe();
1235 })
1236 .viewport_px(200, 400),
1237 );
1238 let _ = tessera_ui::testing::pump_layout_test_session(&mut session, 0);
1239
1240 let now = tessera_ui::time::Instant::now();
1241 let cursor = Some(PxPosition::new(Px::new(48), Px::new(144)));
1242
1243 let moved = PointerChange::new(
1247 now,
1248 1,
1249 CursorEventContent::Moved(PxPosition::new(Px::new(48), Px::new(144))),
1250 GestureState::TapCandidate,
1251 );
1252 let _ = tessera_ui::testing::pump_layout_test_session_with_input(
1253 &mut session,
1254 16_666_667,
1255 vec![moved],
1256 cursor,
1257 );
1258
1259 let press = PointerChange::new(
1260 now,
1261 1,
1262 CursorEventContent::Pressed(PressKeyEventType::Left),
1263 GestureState::TapCandidate,
1264 );
1265 let _ = tessera_ui::testing::pump_layout_test_session_with_input(
1266 &mut session,
1267 33_333_334,
1268 vec![press],
1269 cursor,
1270 );
1271 let release = PointerChange::new(
1272 now,
1273 1,
1274 CursorEventContent::Released(PressKeyEventType::Left),
1275 GestureState::TapCandidate,
1276 );
1277 let mut snapshot = tessera_ui::testing::pump_layout_test_session_with_input(
1278 &mut session,
1279 50_000_001,
1280 vec![release],
1281 cursor,
1282 );
1283 for frame in 3..40u64 {
1284 snapshot = tessera_ui::testing::pump_layout_test_session(
1285 &mut session,
1286 50_000_001 + 16_666_667 * (frame - 2),
1287 );
1288 }
1289
1290 snapshot.node("rail-search-active").exists();
1291 }
1292}
1293
1294#[cfg(all(test, not(target_family = "wasm")))]
1295mod modal_tests {
1296
1297 use tessera_ui::{
1298 HeadlessConfig, HeadlessRenderer, MOUSE_POINTER_ID, PressKeyEventType,
1299 accesskit::{Action, ActionRequest, Node, NodeId, TreeId},
1300 winit::{
1301 event::ElementState,
1302 keyboard::{KeyCode, PhysicalKey},
1303 },
1304 };
1305
1306 use crate::{button::button, text::text, theme::material_theme};
1307
1308 use super::*;
1309
1310 #[tessera]
1311 fn fixture(
1312 output: tessera_ui::testing::TestProbe<Option<(State<bool>, State<usize>, Callback)>>,
1313 ) {
1314 let open = remember(|| false);
1315 let selected = remember(|| 0usize);
1316 let close = Callback::new(move || open.set(false));
1317 output.set(Some((open, selected, close)));
1318 material_theme().child(move || {
1319 modal_navigation_rail()
1320 .is_open(open.get())
1321 .accessibility_label("Mail destinations")
1322 .on_close_request_shared(close)
1323 .main_content(move || {
1324 column()
1325 .modifier(Modifier::new().size(Dp(600.0), Dp(480.0)))
1326 .children(move || {
1327 button()
1328 .accessibility_label("Open rail")
1329 .on_click(move || open.set(true))
1330 .child(|| {
1331 text("Open rail");
1332 });
1333 });
1334 })
1335 .header(move || {
1336 button()
1337 .accessibility_label("Close rail")
1338 .on_click(move || open.set(false))
1339 .child(|| {
1340 text("Close rail");
1341 });
1342 })
1343 .content(move |rail| {
1344 for (index, label) in ["Inbox", "Saved"].into_iter().enumerate() {
1345 navigation_rail_item(rail)
1346 .label(label)
1347 .selected(selected.get() == index)
1348 .on_click(move || {
1349 selected.set(index);
1350 open.set(false);
1351 });
1352 }
1353 });
1354 });
1355 }
1356
1357 fn node(renderer: &HeadlessRenderer, role: Role, label: &str) -> (NodeId, Node) {
1358 renderer
1359 .accessibility_snapshot()
1360 .unwrap()
1361 .nodes
1362 .into_iter()
1363 .find(|(_, node)| {
1364 node.role() == role && node.label() == Some(label) && !node.is_hidden()
1365 })
1366 .unwrap_or_else(|| panic!("missing visible {role:?}: {label}"))
1367 }
1368
1369 fn action(renderer: &mut HeadlessRenderer, target: NodeId, action: Action) {
1370 assert!(renderer.dispatch_accessibility_action(ActionRequest {
1371 action,
1372 target_tree: TreeId::ROOT,
1373 target_node: target,
1374 data: None,
1375 }));
1376 renderer.render_frames(4);
1377 }
1378
1379 #[test]
1380 fn escape_adapter_dismisses_only_pressed_escape() {
1381 use tessera_ui::testing::{
1382 layout_test, pump_layout_test_session, start_layout_test_session,
1383 };
1384
1385 let output = tessera_ui::testing::TestProbe::default();
1386 let observed = output.clone();
1387 let mut session = start_layout_test_session(
1388 layout_test(move || {
1389 fixture(observed.clone());
1390 })
1391 .viewport_px(600, 480),
1392 );
1393 pump_layout_test_session(&mut session, 0);
1394 let open = output.get().expect("mounted modal rail").0;
1395 let close = output.get().expect("mounted modal rail").2;
1396 open.set(true);
1397 assert!(!modal_rail_key(
1398 PhysicalKey::Code(KeyCode::Enter),
1399 ElementState::Pressed,
1400 close
1401 ));
1402 assert!(open.get());
1403 assert!(!modal_rail_key(
1404 PhysicalKey::Code(KeyCode::Escape),
1405 ElementState::Released,
1406 close
1407 ));
1408 assert!(open.get());
1409 assert!(modal_rail_key(
1410 PhysicalKey::Code(KeyCode::Escape),
1411 ElementState::Pressed,
1412 close
1413 ));
1414 assert!(!open.get());
1415 }
1416
1417 #[test]
1418 #[ignore = "Requires a native GPU adapter for modal semantics, input and focus restoration"]
1419 fn modal_scrim_destination_and_escape_adapter_restore_origin_without_focus_activation() {
1420 crate::test_utils::run_isolated_test(
1421 "navigation_rail::modal_tests::modal_scrim_destination_and_escape_adapter_restore_origin_without_focus_activation",
1422 || {
1423 let output = tessera_ui::testing::TestProbe::default();
1424 let observed = output.clone();
1425 let mut renderer = HeadlessRenderer::new(
1426 Box::new(move || {
1427 fixture(observed.clone());
1428 }),
1429 vec![Box::new(crate::TesseraComponents)],
1430 HeadlessConfig {
1431 width: 600,
1432 height: 480,
1433 sample_count: 1,
1434 ..Default::default()
1435 },
1436 )
1437 .unwrap();
1438 renderer.render_frames(4);
1439 for dismiss in 0..3 {
1440 let origin = node(&renderer, Role::Button, "Open rail").0;
1441 action(&mut renderer, origin, Action::Focus);
1442 action(&mut renderer, origin, Action::Click);
1443 assert!(output.get().expect("mounted modal rail").0.get());
1444 let snapshot = renderer.accessibility_snapshot().unwrap();
1445 assert!(!snapshot.nodes.iter().any(|(id, _)| *id == origin));
1446 let dialog = node(&renderer, Role::Dialog, "Mail destinations").1;
1447 let bounds = dialog.bounds().unwrap();
1448 assert_eq!(
1449 (bounds.x0, bounds.y0, bounds.width(), bounds.height()),
1450 (0.0, 0.0, 220.0, 480.0)
1451 );
1452 node(&renderer, Role::Button, "Close rail");
1453 node(&renderer, Role::Tab, "Inbox");
1454 let saved = node(&renderer, Role::Tab, "Saved").0;
1455 let selected = output.get().expect("mounted modal rail").1.get();
1456 action(&mut renderer, saved, Action::Focus);
1457 assert_eq!(renderer.accessibility_snapshot().unwrap().focus, saved);
1458 assert_eq!(output.get().expect("mounted modal rail").1.get(), selected);
1459 assert!(output.get().expect("mounted modal rail").0.get());
1460 match dismiss {
1461 0 => {
1462 let point = PxPosition::from_f32_xy(550.0, 240.0);
1463 renderer.pointer_move(MOUSE_POINTER_ID, point);
1464 renderer.pointer_press(
1465 MOUSE_POINTER_ID,
1466 point,
1467 PressKeyEventType::Left,
1468 );
1469 renderer.pointer_release(
1470 MOUSE_POINTER_ID,
1471 point,
1472 PressKeyEventType::Left,
1473 );
1474 }
1475 1 => action(&mut renderer, saved, Action::Click),
1476 _ => {
1477 assert!(modal_rail_key(
1478 PhysicalKey::Code(KeyCode::Escape),
1479 ElementState::Pressed,
1480 output.get().expect("mounted modal rail").2
1481 ));
1482 }
1483 }
1484 renderer.render_frames(6);
1485 assert!(!output.get().expect("mounted modal rail").0.get());
1486 assert_eq!(renderer.accessibility_snapshot().unwrap().focus, origin);
1487 assert_eq!(
1488 output.get().expect("mounted modal rail").1.get(),
1489 if dismiss == 0 { 0 } else { 1 }
1490 );
1491 }
1492 },
1493 );
1494 }
1495}