Skip to main content

tessera_components/
toolbar.rs

1//! Floating toolbars for frequently used page actions.
2//!
3//! ## Usage
4//!
5//! Keep editing, formatting, and contextual actions close to the current task.
6
7use 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/// Direction in which toolbar actions are arranged.
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20pub enum ToolbarOrientation {
21    /// Arrange actions from left to right.
22    #[default]
23    Horizontal,
24    /// Arrange actions from top to bottom.
25    Vertical,
26}
27
28/// Color treatment for a floating toolbar.
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
30pub enum ToolbarColorStyle {
31    /// Use the surface container color.
32    #[default]
33    Standard,
34    /// Emphasize the toolbar with the primary container color.
35    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/// # floating_toolbar
117///
118/// Group frequent actions in a rounded horizontal or vertical floating surface.
119///
120/// ## Usage
121///
122/// Show contextual editing actions while preserving room for page content.
123///
124/// ## Parameters
125///
126/// - `modifier` — placement and size constraints for the toolbar.
127/// - `orientation` — horizontal by default, or vertical.
128/// - `color_style` — standard surface or vibrant primary container colors.
129/// - `container_color`, `content_color` — optional color overrides.
130/// - `elevation` — shadow elevation, 6dp by default; override with zero when
131///   the background provides enough visual separation.
132/// - `expanded` — whether optional leading and trailing actions are visible.
133/// - `leading`, `trailing` — additional actions shown while expanded.
134/// - `content` — persistent actions, preferably with 48dp touch targets.
135/// - `accessibility_label` — name announced for the toolbar group.
136///
137/// ## Examples
138///
139/// ```
140/// use tessera_components::{text::text, toolbar::floating_toolbar};
141/// use tessera_ui::{remember, tessera};
142///
143/// #[tessera]
144/// fn editor_actions() {
145///     let expanded = remember(|| true);
146///     floating_toolbar().expanded(expanded.get()).content(|| {
147///         text("Editing actions");
148///     });
149/// }
150/// ```
151#[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/// # docked_toolbar
259///
260/// Present frequent actions across a full-width surface at the edge of a page.
261///
262/// ## Usage
263///
264/// Keep page-level editing actions available at the bottom of a workspace.
265///
266/// ## Parameters
267///
268/// - `modifier` — placement and optional size constraints.
269/// - `color_style` — standard surface or vibrant primary container colors.
270/// - `container_color`, `content_color` — optional color overrides.
271/// - `content` — action controls with 48dp touch targets, spaced between 4dp
272///   and 32dp.
273/// - `accessibility_label` — name announced for the toolbar group.
274///
275/// ## Examples
276///
277/// ```
278/// use tessera_components::{text::text, toolbar::docked_toolbar};
279/// use tessera_ui::{remember, tessera};
280///
281/// #[tessera]
282/// fn editing_actions() {
283///     let saved = remember(|| false);
284///     docked_toolbar().content(move || {
285///         text(if saved.get() { "Saved" } else { "Editing" });
286///     });
287/// }
288/// ```
289#[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}