1use tessera_ui::{
8 AxisConstraint, Color, ComputedData, Constraint, Dp, LayoutResult, MeasurementError, Modifier,
9 Px, PxPosition, RenderSlot,
10 accesskit::Role,
11 layout::{MeasureScope, layout},
12 modifier::FocusModifierExt as _,
13 tessera, use_context,
14};
15
16use crate::{modifier::ModifierExt, shape_def::Shape, surface::surface, theme::MaterialTheme};
17
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20pub enum ToolbarOrientation {
21 #[default]
23 Horizontal,
24 Vertical,
26}
27
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
30pub enum ToolbarColorStyle {
31 #[default]
33 Standard,
34 Vibrant,
36}
37
38#[tessera]
39fn toolbar_actions(visible: bool, actions: Option<RenderSlot>) {
40 if visible && let Some(actions) = actions {
41 actions.render();
42 }
43}
44
45#[derive(Clone, Copy, PartialEq)]
46struct FloatingToolbarLayout {
47 orientation: ToolbarOrientation,
48}
49
50impl tessera_ui::LayoutModifierNode for FloatingToolbarLayout {
51 fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
52 let horizontal = self.orientation == ToolbarOrientation::Horizontal;
53 let parent = *input.parent_constraint().as_ref();
54 let (main, cross) = if horizontal {
55 (parent.width, parent.height)
56 } else {
57 (parent.height, parent.width)
58 };
59 let children = input.children();
60 let gap = Dp(4.0).to_px();
61 let gaps = gap * children.len().saturating_sub(1) as i32;
62 let mut total = Px::ZERO;
63 let mut max_cross = Px::ZERO;
64 let mut measured = Vec::with_capacity(children.len());
65 for child in children {
66 let main_constraint = AxisConstraint::new(
67 Px::ZERO,
68 main.resolve_max()
69 .map(|max| (max - gaps - total).max(Px::ZERO)),
70 );
71 let cross_constraint = AxisConstraint::new(Px::ZERO, cross.resolve_max());
72 let constraint = if horizontal {
73 Constraint::new(main_constraint, cross_constraint)
74 } else {
75 Constraint::new(cross_constraint, main_constraint)
76 };
77 let size = child.measure(input, &constraint)?;
78 let (child_main, child_cross) = if horizontal {
79 (size.width, size.height)
80 } else {
81 (size.height, size.width)
82 };
83 total += child_main;
84 max_cross = max_cross.max(child_cross);
85 measured.push((child, child_main, child_cross));
86 }
87 let main_size = main.clamp(total + gaps);
88 let cross_size = cross.clamp(max_cross);
89 let size = if horizontal {
90 ComputedData {
91 width: main_size,
92 height: cross_size,
93 }
94 } else {
95 ComputedData {
96 width: cross_size,
97 height: main_size,
98 }
99 };
100 let mut result = LayoutResult::new(size);
101 let mut cursor = Px::ZERO;
102 for (child, child_main, child_cross) in measured {
103 let cross_offset = (cross_size - child_cross) / 2;
104 let position = if horizontal {
105 PxPosition::new(cursor, cross_offset)
106 } else {
107 PxPosition::new(cross_offset, cursor)
108 };
109 result.place_child(child, position);
110 cursor += child_main + gap;
111 }
112 Ok(result)
113 }
114}
115
116#[tessera]
152pub fn floating_toolbar(
153 modifier: Option<Modifier>,
154 orientation: Option<ToolbarOrientation>,
155 color_style: Option<ToolbarColorStyle>,
156 container_color: Option<Color>,
157 content_color: Option<Color>,
158 elevation: Option<Dp>,
159 expanded: Option<bool>,
160 leading: Option<RenderSlot>,
161 content: Option<RenderSlot>,
162 trailing: Option<RenderSlot>,
163 #[prop(into)] accessibility_label: Option<String>,
164) {
165 let scheme = use_context::<MaterialTheme>()
166 .expect("MaterialTheme must be provided")
167 .get()
168 .color_scheme;
169 let vibrant = color_style.unwrap_or_default() == ToolbarColorStyle::Vibrant;
170 let background = container_color.unwrap_or(if vibrant {
171 scheme.primary_container
172 } else {
173 scheme.surface_container
174 });
175 let foreground = content_color.unwrap_or(if vibrant {
176 scheme.on_primary_container
177 } else {
178 scheme.on_surface_variant
179 });
180 let orientation = orientation.unwrap_or_default();
181 let expanded = expanded.unwrap_or(true);
182 surface()
183 .modifier(modifier.unwrap_or_default().focus_group())
184 .style(background.into())
185 .content_color(foreground)
186 .shape(Shape::CAPSULE)
187 .elevation(elevation.unwrap_or(Dp(6.0)))
188 .block_input(false)
189 .accessibility_role(Role::Toolbar)
190 .accessibility_label_optional(accessibility_label)
191 .child(move || {
192 let actions = RenderSlot::new(move || {
193 toolbar_actions(expanded).actions_optional(leading);
194 toolbar_actions(true).actions_optional(content);
195 toolbar_actions(expanded).actions_optional(trailing);
196 });
197 let modifier = match orientation {
198 ToolbarOrientation::Horizontal => Modifier::new().height(Dp(64.0)),
199 ToolbarOrientation::Vertical => Modifier::new().width(Dp(64.0)),
200 };
201 layout()
202 .modifier(modifier.padding_all(Dp(8.0)))
203 .policy(FloatingToolbarLayout { orientation })
204 .child_shared(actions);
205 });
206}
207
208#[derive(Clone, Copy, PartialEq)]
209struct DockedToolbarLayout;
210
211impl tessera_ui::LayoutModifierNode for DockedToolbarLayout {
212 fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
213 let padding = Dp(16.0).to_px();
214 let height = input.parent_constraint().height().clamp(Dp(64.0).to_px());
215 let children = input.children();
216 let constraints = Constraint::new(
217 AxisConstraint::new(
218 Px::ZERO,
219 input
220 .parent_constraint()
221 .width()
222 .resolve_max()
223 .map(|width| (width - padding * 2).max(Px::ZERO)),
224 ),
225 AxisConstraint::new(Px::ZERO, Some((height - Dp(16.0).to_px()).max(Px::ZERO))),
226 );
227 let mut measured = Vec::with_capacity(children.len());
228 let mut total = Px::ZERO;
229 for child in children {
230 let size = child.measure(input, &constraints)?;
231 total += size.width;
232 measured.push((child, size));
233 }
234 let gaps = measured.len().saturating_sub(1) as i32;
235 let min_gap = Dp(4.0).to_px();
236 let max_gap = Dp(32.0).to_px();
237 let width = input
238 .parent_constraint()
239 .width()
240 .resolve_max()
241 .unwrap_or(total + padding * 2 + min_gap * gaps);
242 let width = input.parent_constraint().width().clamp(width);
243 let gap = if gaps == 0 {
244 Px::ZERO
245 } else {
246 ((width - padding * 2 - total) / gaps).clamp(min_gap, max_gap)
247 };
248 let mut cursor = ((width - total - gap * gaps) / 2).max(padding);
249 let mut result = LayoutResult::new(ComputedData { width, height });
250 for (child, size) in measured {
251 result.place_child(child, PxPosition::new(cursor, (height - size.height) / 2));
252 cursor += size.width + gap;
253 }
254 Ok(result)
255 }
256}
257
258#[tessera]
290pub fn docked_toolbar(
291 modifier: Option<Modifier>,
292 color_style: Option<ToolbarColorStyle>,
293 container_color: Option<Color>,
294 content_color: Option<Color>,
295 content: Option<RenderSlot>,
296 #[prop(into)] accessibility_label: Option<String>,
297) {
298 let scheme = use_context::<MaterialTheme>()
299 .expect("MaterialTheme must be provided")
300 .get()
301 .color_scheme;
302 let vibrant = color_style.unwrap_or_default() == ToolbarColorStyle::Vibrant;
303 surface()
304 .modifier(modifier.unwrap_or_default().fill_max_width().focus_group())
305 .style(
306 container_color
307 .unwrap_or(if vibrant {
308 scheme.primary_container
309 } else {
310 scheme.surface_container
311 })
312 .into(),
313 )
314 .content_color(content_color.unwrap_or(if vibrant {
315 scheme.on_primary_container
316 } else {
317 scheme.on_surface_variant
318 }))
319 .shape(Shape::RECTANGLE)
320 .elevation(Dp(0.0))
321 .block_input(false)
322 .accessibility_role(Role::Toolbar)
323 .accessibility_label_optional(accessibility_label)
324 .child(move || {
325 layout()
326 .policy(DockedToolbarLayout)
327 .child_shared(content.unwrap_or_else(RenderSlot::empty));
328 });
329}
330
331#[cfg(test)]
332mod tests {
333
334 use tessera_ui::{
335 Modifier, State, remember, tessera,
336 testing::{layout_test, pump_layout_test_session, start_layout_test_session},
337 };
338
339 use crate::{spacer::spacer, theme::material_theme};
340
341 use super::*;
342
343 #[tessera]
344 fn fixture(
345 orientation: ToolbarOrientation,
346 expanded: bool,
347 output: tessera_ui::testing::TestProbe<Option<State<bool>>>,
348 content_output: tessera_ui::testing::TestProbe<Option<State<Dp>>>,
349 ) {
350 let expanded = remember(|| expanded);
351 output.set(Some(expanded));
352 let observe_content =
353 tessera_ui::CallbackWith::new(move |size| content_output.set(Some(size)));
354 material_theme().child(move || {
355 crate::column::column().children(move || {
356 crate::boxed::boxed()
357 .modifier(Modifier::new().semantics(crate::modifier::SemanticsArgs {
358 test_tag: Some("toolbar".into()),
359 ..Default::default()
360 }))
361 .children(move || {
362 floating_toolbar()
363 .orientation(orientation)
364 .expanded(expanded.get())
365 .leading(|| {
366 spacer().modifier(
367 Modifier::new().size(Dp(48.0), Dp(48.0)).semantics(
368 crate::modifier::SemanticsArgs {
369 test_tag: Some("leading-action".into()),
370 ..Default::default()
371 },
372 ),
373 );
374 })
375 .content(move || {
376 let size = remember(|| Dp(48.0));
377 observe_content.call(size);
378 spacer().modifier(
379 Modifier::new().size(size.get(), size.get()).semantics(
380 crate::modifier::SemanticsArgs {
381 test_tag: Some("content-action".into()),
382 ..Default::default()
383 },
384 ),
385 );
386 })
387 .trailing(|| {
388 spacer().modifier(
389 Modifier::new().size(Dp(48.0), Dp(48.0)).semantics(
390 crate::modifier::SemanticsArgs {
391 test_tag: Some("trailing-action".into()),
392 ..Default::default()
393 },
394 ),
395 );
396 });
397 });
398 });
399 });
400 }
401
402 #[test]
403 fn toolbar_measures_both_orientations_and_collapsed_actions() {
404 for (orientation, expanded, width, height) in [
405 (ToolbarOrientation::Horizontal, true, 168, 64),
406 (ToolbarOrientation::Vertical, true, 64, 168),
407 (ToolbarOrientation::Horizontal, false, 64, 64),
408 (ToolbarOrientation::Vertical, false, 64, 64),
409 ] {
410 let output = tessera_ui::testing::TestProbe::default();
411 let content_output = tessera_ui::testing::TestProbe::default();
412 let probe = output.clone();
413 let content_probe = content_output.clone();
414 let mut session = start_layout_test_session(
415 layout_test(move || {
416 fixture(orientation, expanded, probe.clone(), content_probe.clone());
417 })
418 .viewport_px(400, 400),
419 );
420 let snapshot = pump_layout_test_session(&mut session, 0);
421 snapshot.node("toolbar").size(width, height);
422 if expanded {
423 for (tag, offset) in [
424 ("leading-action", 8),
425 ("content-action", 60),
426 ("trailing-action", 112),
427 ] {
428 let (x, y) = match orientation {
429 ToolbarOrientation::Horizontal => (offset, 8),
430 ToolbarOrientation::Vertical => (8, offset),
431 };
432 snapshot.node(tag).size(48, 48).position(x, y);
433 }
434 let toolbar = snapshot.node("toolbar").bounds();
435 let trailing = snapshot.node("trailing-action").bounds();
436 let end_padding = match orientation {
437 ToolbarOrientation::Horizontal => {
438 toolbar.x + toolbar.width - trailing.x - trailing.width
439 }
440 ToolbarOrientation::Vertical => {
441 toolbar.y + toolbar.height - trailing.y - trailing.height
442 }
443 };
444 assert_eq!(end_padding, Dp(8.0).to_px());
445 } else {
446 snapshot.node("content-action").size(48, 48).position(8, 8);
447 }
448 }
449 }
450
451 #[test]
452 fn floating_toolbar_collapse_and_reexpand_preserve_live_content_state() {
453 for orientation in [ToolbarOrientation::Horizontal, ToolbarOrientation::Vertical] {
454 let horizontal = orientation == ToolbarOrientation::Horizontal;
455 let output = tessera_ui::testing::TestProbe::default();
456 let content_output = tessera_ui::testing::TestProbe::default();
457 let probe = output.clone();
458 let content_probe = content_output.clone();
459 let mut session = start_layout_test_session(
460 layout_test(move || {
461 fixture(orientation, true, probe.clone(), content_probe.clone());
462 })
463 .viewport_px(400, 400),
464 );
465 let initial = pump_layout_test_session(&mut session, 0);
466 initial.node("toolbar").size(
467 if horizontal { 168 } else { 64 },
468 if horizontal { 64 } else { 168 },
469 );
470 output.get().expect("fixture state").set(false);
471 let collapsed = pump_layout_test_session(&mut session, 16_666_667);
472 collapsed.node("toolbar").size(64, 64);
473 collapsed.node("content-action").size(48, 48).position(8, 8);
474
475 content_output
476 .get()
477 .expect("persistent content state")
478 .set(Dp(40.0));
479 let updated = pump_layout_test_session(&mut session, 33_333_334);
480 updated.node("toolbar").size(
481 if horizontal { 56 } else { 64 },
482 if horizontal { 64 } else { 56 },
483 );
484 updated.node("content-action").size(40, 40).position(
485 if horizontal { 8 } else { 12 },
486 if horizontal { 12 } else { 8 },
487 );
488
489 output.get().expect("fixture state").set(true);
490 let expanded = pump_layout_test_session(&mut session, 50_000_001);
491 expanded.node("toolbar").size(
492 if horizontal { 160 } else { 64 },
493 if horizontal { 64 } else { 160 },
494 );
495 expanded.node("leading-action").size(48, 48).position(8, 8);
496 expanded.node("content-action").size(40, 40).position(
497 if horizontal { 60 } else { 12 },
498 if horizontal { 12 } else { 60 },
499 );
500 expanded.node("trailing-action").size(48, 48).position(
501 if horizontal { 104 } else { 8 },
502 if horizontal { 8 } else { 104 },
503 );
504 assert_eq!(
505 content_output
506 .get()
507 .expect("persistent content state")
508 .get(),
509 Dp(40.0)
510 );
511 }
512 }
513
514 #[cfg(not(target_family = "wasm"))]
515 #[tessera]
516 fn elevation_fixture(output: tessera_ui::testing::TestProbe<Option<State<Option<Dp>>>>) {
517 let elevation = remember(|| None::<Dp>);
518 output.set(Some(elevation));
519 material_theme().child(move || {
520 surface()
521 .modifier(Modifier::new().fill_max_size())
522 .style(Color::WHITE.into())
523 .shape(Shape::RECTANGLE)
524 .child(move || {
525 crate::boxed::boxed()
526 .modifier(Modifier::new().fill_max_size())
527 .alignment(crate::alignment::Alignment::Center)
528 .children(move || {
529 floating_toolbar()
530 .container_color(Color::WHITE)
531 .elevation_optional(elevation.get())
532 .content(|| {
533 spacer().modifier(Modifier::new().size(Dp(48.0), Dp(48.0)));
534 });
535 });
536 });
537 });
538 }
539
540 #[cfg(not(target_family = "wasm"))]
541 #[test]
542 #[ignore = "requires a native GPU adapter"]
543 fn floating_toolbar_default_shadow_matches_6dp_and_zero_override_removes_it() {
544 crate::test_utils::run_isolated_test(
545 "toolbar::tests::floating_toolbar_default_shadow_matches_6dp_and_zero_override_removes_it",
546 check_default_shadow,
547 );
548 }
549
550 #[cfg(not(target_family = "wasm"))]
551 fn check_default_shadow() {
552 use tessera_ui::headless::{HeadlessConfig, HeadlessRenderer};
553
554 let output = tessera_ui::testing::TestProbe::default();
555 let probe = output.clone();
556 let mut renderer = HeadlessRenderer::new(
557 Box::new(move || elevation_fixture(probe.clone())),
558 vec![Box::new(crate::TesseraComponents)],
559 HeadlessConfig {
560 width: 160,
561 height: 160,
562 ..Default::default()
563 },
564 )
565 .expect("native GPU renderer");
566 renderer.render_frames(4);
567 let default = renderer.read_pixels().expect("default elevation pixels");
568 let elevation = output.get().expect("fixture elevation state");
569 elevation.set(Some(Dp(6.0)));
570 renderer.render_frames(4);
571 assert_eq!(
572 default,
573 renderer.read_pixels().expect("explicit 6dp pixels"),
574 "the default must reach the same surface drawing as explicit 6dp"
575 );
576
577 elevation.set(Some(Dp::ZERO));
578 renderer.render_frames(4);
579 let flat = renderer.read_pixels().expect("zero elevation pixels");
580 let shadow_pixels = |pixels: &[u8]| {
581 let mut crop = Vec::new();
582 for y in 112..128 {
583 for x in 32..128 {
584 let offset = (y * 160 + x) * 4;
585 crop.extend_from_slice(&pixels[offset..offset + 4]);
586 }
587 }
588 crop
589 };
590 let default_shadow = shadow_pixels(&default);
591 let flat_shadow = shadow_pixels(&flat);
592 assert!(
593 flat_shadow.chunks_exact(4).all(|pixel| pixel == [255; 4]),
594 "zero elevation must leave only the white background below the toolbar"
595 );
596 assert_ne!(
597 default_shadow, flat_shadow,
598 "the default must draw a visible shadow outside the 64dp container"
599 );
600
601 elevation.set(None);
602 renderer.render_frames(4);
603 assert_eq!(
604 default,
605 renderer.read_pixels().expect("restored default pixels")
606 );
607 }
608
609 #[tessera]
610 fn docked_fixture() {
611 material_theme().child(|| {
612 crate::column::column().children(|| {
613 crate::boxed::boxed()
614 .modifier(Modifier::new().semantics(crate::modifier::SemanticsArgs {
615 test_tag: Some("docked".into()),
616 ..Default::default()
617 }))
618 .children(|| {
619 docked_toolbar().content(|| {
620 spacer().modifier(Modifier::new().size(Dp(48.0), Dp(48.0)).semantics(
621 crate::modifier::SemanticsArgs {
622 test_tag: Some("first-action".into()),
623 ..Default::default()
624 },
625 ));
626 spacer().modifier(Modifier::new().size(Dp(48.0), Dp(48.0)).semantics(
627 crate::modifier::SemanticsArgs {
628 test_tag: Some("second-action".into()),
629 ..Default::default()
630 },
631 ));
632 });
633 });
634 });
635 });
636 }
637
638 #[test]
639 fn docked_toolbar_fills_the_available_width_at_64dp_height() {
640 for (width, gap) in [(132, 4), (140, 12), (320, 32), (800, 32)] {
641 let snapshot = layout_test(|| {
642 docked_fixture();
643 })
644 .viewport_px(width, 400)
645 .run();
646 snapshot.node("docked").size(width as i32, 64);
647 let first = snapshot.node("first-action").bounds();
648 let second = snapshot.node("second-action").bounds();
649 assert_eq!(second.x - first.x - first.width, Dp(gap as f64).to_px());
650 assert_eq!(first.y, Dp(8.0).to_px());
651 }
652 }
653}