1use std::rc::Rc;
6
7use frus_core::{BorderRadius, Color, Insets, Rect, Scene, ShapeBorder};
8use frus_layout::{FlexDirection, Style};
9
10use crate::interaction::Status;
11use crate::menu::{Panel, PanelKind, PanelStyle};
12use crate::portal::Placement;
13use crate::theme::Theme;
14use crate::transparent::Shared;
15use crate::widget::Widget;
16
17pub struct MenuAnchor<Msg = crate::callback::Callback> {
26 open: bool,
27 children: Vec<Box<dyn Widget<Msg>>>,
29 content: Option<Rc<dyn Widget<Msg>>>,
32 look: PanelStyle,
33 on_dismiss: Option<Msg>,
34}
35
36impl<Msg: Clone + 'static> MenuAnchor<Msg> {
37 pub fn new(anchor: impl Widget<Msg> + 'static, open: bool, on_dismiss: Msg) -> Self {
40 Self {
41 open,
42 children: vec![Box::new(anchor)],
43 content: None,
44 look: PanelStyle::default(),
45 on_dismiss: if open { Some(on_dismiss) } else { None },
46 }
47 }
48
49 pub fn content(mut self, content: impl Widget<Msg> + 'static) -> Self {
51 if self.open {
52 let content: Rc<dyn Widget<Msg>> = Rc::new(content);
53 self.content = Some(content);
54 self.rebuild();
55 }
56 self
57 }
58
59 #[must_use]
61 pub fn background(mut self, color: Color) -> Self {
62 self.look.background = Some(color);
63 self.rebuild();
64 self
65 }
66
67 #[must_use]
69 pub fn shape(mut self, shape: ShapeBorder) -> Self {
70 self.look.shape = Some(shape);
71 self.rebuild();
72 self
73 }
74
75 #[must_use]
79 pub fn radius(self, radius: impl Into<BorderRadius>) -> Self {
80 self.shape(ShapeBorder::rounded(radius.into()))
81 }
82
83 #[must_use]
85 pub fn elevation(mut self, elevation: f32) -> Self {
86 self.look.elevation = Some(elevation);
87 self.rebuild();
88 self
89 }
90
91 #[must_use]
94 pub fn shadow_color(mut self, color: Color) -> Self {
95 self.look.shadow_color = Some(color);
96 self.rebuild();
97 self
98 }
99
100 #[must_use]
103 pub fn menu_padding(mut self, padding: Insets) -> Self {
104 self.look.padding = Some(padding);
105 self.rebuild();
106 self
107 }
108
109 fn rebuild(&mut self) {
111 self.children.truncate(1);
112 if let (true, Some(content)) = (self.open, &self.content) {
113 self.children.push(Box::new(Panel::new(
114 PanelKind::Menu,
115 self.look,
116 vec![Box::new(Shared::new(content.clone()))],
117 )));
118 }
119 }
120}
121
122impl<Msg: Clone> Widget<Msg> for MenuAnchor<Msg> {
123 fn style(&self) -> Style {
124 Style {
125 flex_direction: FlexDirection::Column,
126 ..Default::default()
127 }
128 }
129
130 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
131 &self.children
132 }
133
134 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
135
136 fn on_click(&self) -> Option<Msg> {
137 None
138 }
139
140 fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
141 self.children
142 .get(1)
143 .map(|content| (content.as_ref(), Placement::Below))
144 }
145
146 fn overlay_dismiss(&self) -> Option<Msg> {
147 self.on_dismiss.clone()
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use crate::menu::probe::{blurred, crisp};
155 use crate::{build_ui, Container, Runtime, Size, Text};
156
157 #[derive(Clone, Debug, PartialEq)]
158 enum Msg {
159 Close,
160 }
161
162 fn anchor() -> Container<Msg> {
163 Container::<Msg>::new().width(40.0).height(30.0)
164 }
165
166 const MARK: Color = Color::rgb(0.9, 0.1, 0.1);
167
168 fn open() -> MenuAnchor<Msg> {
170 MenuAnchor::new(anchor(), true, Msg::Close).content(
171 Container::<Msg>::new()
172 .width(100.0)
173 .height(50.0)
174 .color(MARK),
175 )
176 }
177
178 fn frame(menu: &MenuAnchor<Msg>, theme: &Theme) -> crate::Ui<Msg> {
179 build_ui(menu, Size::new(400.0, 400.0), &Runtime::default(), theme)
180 }
181
182 #[test]
183 fn closed_has_no_overlay() {
184 let p = MenuAnchor::new(anchor(), false, Msg::Close).content(Text::new("hi"));
185 assert!(Widget::<Msg>::overlay(&p).is_none());
186 }
187
188 #[test]
189 fn open_floats_content_and_dismisses() {
190 let p = MenuAnchor::new(anchor(), true, Msg::Close).content(Text::new("hi"));
191 assert!(Widget::<Msg>::overlay(&p).is_some());
192 assert_eq!(Widget::<Msg>::overlay_dismiss(&p), Some(Msg::Close));
193 }
194
195 #[test]
204 fn the_content_floats_on_one_panel_with_a_shadow_three_high() {
205 let theme = Theme::default();
206 let ui = frame(&open(), &theme);
207 let painted = crisp(ui.scene());
208 let panels: Vec<_> = painted
209 .iter()
210 .filter(|r| r.color == theme.scheme.surface_container)
211 .collect();
212 assert_eq!(panels.len(), 1, "one surface: {painted:#?}");
213 let panel = panels[0];
214 let content = painted
215 .iter()
216 .position(|r| r.color == MARK)
217 .expect("the content is drawn");
218 let behind = painted
219 .iter()
220 .position(|r| r.color == theme.scheme.surface_container)
221 .unwrap();
222 assert!(behind < content, "the panel is painted behind the content");
223 let content = painted[content];
224 assert_eq!(
225 panel.rect.width, content.rect.width,
226 "as wide as its content"
227 );
228 assert_eq!(
229 panel.rect.height,
230 content.rect.height + 16.0,
231 "and eight more each way"
232 );
233 assert_eq!(content.rect.y, panel.rect.y + 8.0);
234 assert!(panel.radius != BorderRadius::ZERO, "rounded");
235 assert!(painted.iter().all(|r| r.border_width == 0.0), "no outline");
236
237 let shadows = blurred(ui.scene());
238 assert_eq!(shadows.len(), 1, "one shadow: {shadows:#?}");
239 let shadow = shadows[0];
240 assert_eq!(shadow.blur, 20.0, "three high: four a step from eight");
241 assert_eq!(
242 shadow.rect.y,
243 panel.rect.y + 6.0 - 20.0,
244 "dropped two a step"
245 );
246 assert_eq!(shadow.color, theme.scheme.shadow.with_alpha(0.30));
247 }
248
249 #[test]
252 fn the_panel_answers_to_its_theme_and_to_its_caller() {
253 let mut theme = Theme::default();
254 let (surface, shade) = (Color::rgb(0.2, 0.4, 0.6), Color::rgba(0.0, 0.0, 0.5, 0.5));
255 theme.widgets.menu.background = Some(surface);
256 theme.widgets.menu.radius = Some(3.0);
257 theme.widgets.menu.elevation = Some(1.0);
258 theme.widgets.menu.shadow_color = Some(shade);
259 theme.widgets.menu.padding = Some(Insets::new(4.0, 4.0, 4.0, 4.0));
260
261 let ui = frame(&open(), &theme);
262 let panel = *crisp(ui.scene())
263 .iter()
264 .find(|r| r.color == surface)
265 .expect("the theme's surface");
266 assert_eq!(
267 panel.radius,
268 BorderRadius::uniform(3.0),
269 "the theme's corner"
270 );
271 assert_eq!(
272 (panel.rect.width, panel.rect.height),
273 (108.0, 58.0),
274 "its room"
275 );
276 let shadow = blurred(ui.scene())[0];
277 assert_eq!(
278 (shadow.blur, shadow.color),
279 (12.0, shade),
280 "its height and colour"
281 );
282
283 let (own, own_shade) = (Color::rgb(0.9, 0.9, 0.1), Color::rgba(0.5, 0.0, 0.0, 0.4));
284 let told = open()
285 .background(own)
286 .radius(9.0)
287 .elevation(2.0)
288 .shadow_color(own_shade)
289 .menu_padding(Insets::ZERO);
290 let ui = frame(&told, &theme);
291 let panel = *crisp(ui.scene())
292 .iter()
293 .find(|r| r.color == own)
294 .expect("the caller's surface");
295 assert_eq!(panel.radius, BorderRadius::uniform(9.0));
296 assert_eq!((panel.rect.width, panel.rect.height), (100.0, 50.0));
297 let shadow = blurred(ui.scene())[0];
298 assert_eq!((shadow.blur, shadow.color), (16.0, own_shade));
299 }
300
301 #[test]
304 fn a_transparent_shadow_or_a_flat_panel_casts_nothing() {
305 let theme = Theme::default();
306 let clear = open().shadow_color(Color::TRANSPARENT);
307 assert!(blurred(frame(&clear, &theme).scene()).is_empty());
308 assert!(blurred(frame(&open().elevation(0.0), &theme).scene()).is_empty());
309 assert!(
310 crisp(frame(&open().elevation(0.0), &theme).scene())
311 .iter()
312 .all(|r| r.color != theme.scheme.shadow.with_alpha(0.30)),
313 "not even an unblurred box of it"
314 );
315
316 let mut themed = Theme::default();
317 themed.widgets.menu.shadow_color = Some(Color::TRANSPARENT);
318 assert!(blurred(frame(&open(), &themed).scene()).is_empty());
319 }
320
321 #[test]
329 fn the_builders_can_be_written_in_any_order() {
330 let theme = Theme::default();
331 let colour = Color::rgb(0.1, 0.7, 0.3);
332 type Builder = fn(MenuAnchor<Msg>) -> MenuAnchor<Msg>;
333 let builders: [(&str, Builder); 5] = [
334 ("background", |m| m.background(Color::rgb(0.1, 0.7, 0.3))),
335 ("shape", |m| m.radius(2.0)),
336 ("elevation", |m| m.elevation(5.0)),
337 ("shadow_color", |m| {
338 m.shadow_color(Color::rgba(0.0, 0.5, 0.0, 0.5))
339 }),
340 ("menu_padding", |m| {
341 m.menu_padding(Insets::new(1.0, 2.0, 3.0, 4.0))
342 }),
343 ];
344 for (name, say) in builders {
345 let first = say(MenuAnchor::new(anchor(), true, Msg::Close)).content(
346 Container::<Msg>::new()
347 .width(100.0)
348 .height(50.0)
349 .color(MARK),
350 );
351 let last = say(open());
352 let (before, after) = (frame(&first, &theme), frame(&last, &theme));
353 assert_eq!(crisp(before.scene()), crisp(after.scene()), "{name}");
354 assert_eq!(blurred(before.scene()), blurred(after.scene()), "{name}");
355 let untold = frame(&open(), &theme);
356 assert_ne!(
357 (crisp(after.scene()), blurred(after.scene())),
358 (crisp(untold.scene()), blurred(untold.scene())),
359 "{name} after the content changed something"
360 );
361 }
362 assert!(crisp(frame(&open().background(colour), &theme).scene())
363 .iter()
364 .any(|r| r.color == colour));
365 }
366
367 #[test]
369 fn a_press_on_the_panel_does_not_close_it() {
370 let ui = frame(&open(), &Theme::default());
371 let press = |x, y| {
372 ui.hit(frus_core::Point::new(x, y))
373 .and_then(|id| ui.msg_for(id))
374 };
375 assert_eq!(press(50.0, 34.0), None, "the room above the content");
376 assert_eq!(press(390.0, 390.0), Some(Msg::Close));
377 }
378}