Skip to main content

tessera_components/menus/
appearance.rs

1//! Shapes and color styles for expressive vertical menus.
2//!
3//! ## Usage
4//!
5//! Organize contextual actions into distinct groups with optional selection.
6
7use tessera_ui::{
8    Color, Dp, FocusScopeNode, Modifier, RenderSlot,
9    accesskit::Role,
10    current_frame_nanos,
11    layout::layout,
12    modifier::{FocusModifierExt as _, ModifierCapabilityExt as _},
13    provide_context, receive_frame_nanos, remember, tessera, use_context,
14};
15
16use crate::{
17    alignment::CrossAxisAlignment,
18    column::column,
19    modifier::{ModifierExt as _, Padding},
20    shape_def::{RoundedCorner, Shape},
21    surface::{SurfaceStyle, surface},
22    theme::{MaterialColorScheme, MaterialTheme},
23};
24
25use super::motion::{MenuShapeHover, MenuShapeMotion, advance};
26
27/// Color emphasis for vertical menus and their items.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29pub enum MenuColorStyle {
30    /// Surface-based containers with tertiary selection.
31    #[default]
32    Standard,
33    /// Tertiary containers with stronger selected emphasis.
34    Vibrant,
35}
36
37impl MenuColorStyle {
38    pub(super) fn container(self, scheme: &MaterialColorScheme) -> Color {
39        match self {
40            Self::Standard => scheme.surface_container_low,
41            Self::Vibrant => scheme.tertiary_container,
42        }
43    }
44
45    pub(super) fn selected_container(self, scheme: &MaterialColorScheme) -> Color {
46        match self {
47            Self::Standard => scheme.tertiary_container,
48            Self::Vibrant => scheme.tertiary,
49        }
50    }
51
52    pub(super) fn content(self, scheme: &MaterialColorScheme, selected: bool) -> Color {
53        match (self, selected) {
54            (Self::Standard, false) => scheme.on_surface,
55            (Self::Standard, true) | (Self::Vibrant, false) => scheme.on_tertiary_container,
56            (Self::Vibrant, true) => scheme.on_tertiary,
57        }
58    }
59
60    pub(super) fn supporting(self, scheme: &MaterialColorScheme, selected: bool) -> Color {
61        if self == Self::Standard && !selected {
62            scheme.on_surface_variant
63        } else {
64            self.content(scheme, selected)
65        }
66    }
67}
68
69/// Position-dependent corners for menu groups and items.
70#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
71pub enum MenuPosition {
72    /// A single group or item.
73    #[default]
74    Standalone,
75    /// The first group or item in a sequence.
76    First,
77    /// A group or item between the first and last.
78    Middle,
79    /// The final group or item in a sequence.
80    Last,
81}
82
83/// Default dimensions and shapes for vertical menus.
84pub struct MenuDefaults;
85
86impl MenuDefaults {
87    /// Gap between separately surfaced groups.
88    pub const GROUP_SPACING: Dp = Dp(2.0);
89    /// Minimum visible item height, before minimum touch-target expansion.
90    pub const ITEM_HEIGHT: Dp = Dp(44.0);
91
92    /// Returns the resting shape for a group at the specified position.
93    pub fn group_shape(position: MenuPosition) -> Shape {
94        let (top, bottom) = match position {
95            MenuPosition::Standalone => (16.0, 16.0),
96            MenuPosition::First => (16.0, 8.0),
97            MenuPosition::Middle => (8.0, 8.0),
98            MenuPosition::Last => (8.0, 16.0),
99        };
100        corners(top, bottom)
101    }
102
103    /// Returns a menu item's shape, including its selected state.
104    pub fn item_shape(position: MenuPosition, selected: bool) -> Shape {
105        let (top, bottom) = if selected {
106            (12.0, 12.0)
107        } else {
108            match position {
109                MenuPosition::First => (12.0, 4.0),
110                MenuPosition::Last => (4.0, 12.0),
111                MenuPosition::Standalone | MenuPosition::Middle => (4.0, 4.0),
112            }
113        };
114        corners(top, bottom)
115    }
116}
117
118fn corners(top: f64, bottom: f64) -> Shape {
119    Shape::RoundedRectangle {
120        top_left: RoundedCorner::manual(Dp(top), 2.0),
121        top_right: RoundedCorner::manual(Dp(top), 2.0),
122        bottom_right: RoundedCorner::manual(Dp(bottom), 2.0),
123        bottom_left: RoundedCorner::manual(Dp(bottom), 2.0),
124    }
125}
126
127/// # menu_group
128///
129/// Surface a group of related vertical-menu actions.
130///
131/// ## Usage
132///
133/// Separate related actions inside a grouped context menu.
134///
135/// ## Parameters
136///
137/// - `menu` — action owner received from the enclosing menu content slot.
138/// - `position` — outside corners; middle and last groups include a 2dp gap
139///   above. Default corners contract when an activated group loses hover and
140///   keyboard focus.
141/// - `color_style` — color emphasis; inherits the enclosing menu by default.
142/// - `modifier` — optional layout and semantics modifiers.
143/// - `shape` — optional fixed shape override.
144/// - `container_color` — optional group background override.
145/// - `accessibility_label` — optional name describing the group of actions.
146/// - `children` — menu items or other group content.
147///
148/// ## Examples
149///
150/// ```
151/// use tessera_components::{
152///     menus::{MenuController, menu_group, menu_item, menu_provider},
153///     theme::material_theme,
154/// };
155/// use tessera_ui::{remember, tessera};
156///
157/// #[tessera]
158/// fn component() {
159///     let menu = remember(MenuController::new);
160///     menu.with_mut(MenuController::open);
161///     assert!(menu.with(MenuController::is_open));
162///     material_theme().child(move || {
163///         menu_provider()
164///             .controller(menu)
165///             .grouped(true)
166///             .menu_content(move |scope| {
167///                 menu_group().menu(scope).children(move || {
168///                     menu_item().menu(scope).label("Save").on_click(move || {
169///                         menu.with_mut(MenuController::close);
170///                     });
171///                 });
172///             });
173///     });
174/// }
175/// # tessera_ui::testing::layout_test(component).run();
176/// ```
177#[tessera]
178pub fn menu_group(
179    menu: Option<super::MenuScope>,
180    position: Option<MenuPosition>,
181    color_style: Option<MenuColorStyle>,
182    modifier: Option<Modifier>,
183    shape: Option<Shape>,
184    container_color: Option<Color>,
185    #[prop(into)] accessibility_label: Option<String>,
186    children: Option<RenderSlot>,
187) {
188    let inherited_style = use_context::<MenuColorStyle>().map(|style| style.get());
189    let color_style = color_style.or(inherited_style).unwrap_or_default();
190    let position = position.unwrap_or_default();
191    let scheme = use_context::<MaterialTheme>()
192        .expect("MaterialTheme must be provided")
193        .get()
194        .color_scheme;
195    let children = children.unwrap_or_else(RenderSlot::empty);
196    let modifier = modifier.unwrap_or_default();
197    let focus_scope = remember(FocusScopeNode::new).get();
198    let frame = current_frame_nanos();
199    let motion = remember(|| MenuShapeMotion::new(position, frame));
200    let focused = focus_scope.has_focus();
201    if shape.is_none() && motion.with(|motion| motion.needs_update(position, focused)) {
202        motion.with_mut(|motion| motion.update(position, focused, frame));
203    }
204    if shape.is_none() && !motion.with(MenuShapeMotion::is_idle) {
205        receive_frame_nanos(move |frame| advance(motion, frame));
206    }
207    let mut modifier = modifier.focus_scope_with(focus_scope);
208    if shape.is_none() {
209        modifier = modifier.push_pointer_preview_input(MenuShapeHover {
210            motion,
211            host: menu.map(|menu| menu.host),
212            controller: menu.map(|menu| menu.controller),
213        });
214    }
215    layout()
216        .modifier(Modifier::new().padding(Padding::top(
217            if matches!(position, MenuPosition::Middle | MenuPosition::Last) {
218                MenuDefaults::GROUP_SPACING
219            } else {
220                Dp::ZERO
221            },
222        )))
223        .child(move || {
224            surface()
225                .modifier(modifier.clone().fill_max_width())
226                .shape(shape.unwrap_or_else(|| motion.with(MenuShapeMotion::shape)))
227                .style(SurfaceStyle::Filled {
228                    color: container_color.unwrap_or_else(|| color_style.container(&scheme)),
229                })
230                .accessibility_role(Role::Group)
231                .accessibility_label_optional(accessibility_label.clone())
232                .elevation(Dp(3.0))
233                .child(move || {
234                    provide_context(
235                        || color_style,
236                        move || {
237                            let inherited = use_context::<MenuColorStyle>().expect("menu colors");
238                            if inherited.get() != color_style {
239                                inherited.set(color_style);
240                            }
241                            column()
242                                .modifier(
243                                    Modifier::new()
244                                        .fill_max_width()
245                                        .clip_shape(
246                                            shape.unwrap_or_else(|| {
247                                                motion.with(MenuShapeMotion::shape)
248                                            }),
249                                        )
250                                        .padding_symmetric(Dp::ZERO, Dp(2.0)),
251                                )
252                                .cross_axis_alignment(CrossAxisAlignment::Start)
253                                .children_shared(children);
254                        },
255                    );
256                });
257        });
258}