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, colour_of, crisp, height_of};
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!(
239 shadows.len(),
240 3,
241 "one surface's three shadows: {shadows:#?}"
242 );
243 assert_eq!(height_of(ui.scene()), 3.0, "three high");
244 assert_eq!(
245 shadows[1].rect.y + shadows[1].rect.height * 0.5,
246 panel.rect.y + panel.rect.height * 0.5 + 3.0,
247 "the second shadow drops by the height"
248 );
249 assert_eq!(colour_of(ui.scene()), Some(theme.scheme.shadow));
250 }
251
252 #[test]
255 fn the_panel_answers_to_its_theme_and_to_its_caller() {
256 let mut theme = Theme::default();
257 let (surface, shade) = (Color::rgb(0.2, 0.4, 0.6), Color::rgba(0.0, 0.0, 0.5, 0.5));
258 theme.widgets.menu.background = Some(surface);
259 theme.widgets.menu.radius = Some(3.0);
260 theme.widgets.menu.elevation = Some(1.0);
261 theme.widgets.menu.shadow_color = Some(shade);
262 theme.widgets.menu.padding = Some(Insets::new(4.0, 4.0, 4.0, 4.0));
263
264 let ui = frame(&open(), &theme);
265 let panel = *crisp(ui.scene())
266 .iter()
267 .find(|r| r.color == surface)
268 .expect("the theme's surface");
269 assert_eq!(
270 panel.radius,
271 BorderRadius::uniform(3.0),
272 "the theme's corner"
273 );
274 assert_eq!(
275 (panel.rect.width, panel.rect.height),
276 (108.0, 58.0),
277 "its room"
278 );
279 assert_eq!(
280 (height_of(ui.scene()), colour_of(ui.scene())),
281 (1.0, Some(shade)),
282 "its height and colour"
283 );
284
285 let (own, own_shade) = (Color::rgb(0.9, 0.9, 0.1), Color::rgba(0.5, 0.0, 0.0, 0.4));
286 let told = open()
287 .background(own)
288 .radius(9.0)
289 .elevation(2.0)
290 .shadow_color(own_shade)
291 .menu_padding(Insets::ZERO);
292 let ui = frame(&told, &theme);
293 let panel = *crisp(ui.scene())
294 .iter()
295 .find(|r| r.color == own)
296 .expect("the caller's surface");
297 assert_eq!(panel.radius, BorderRadius::uniform(9.0));
298 assert_eq!((panel.rect.width, panel.rect.height), (100.0, 50.0));
299 assert_eq!(
300 (height_of(ui.scene()), colour_of(ui.scene())),
301 (2.0, Some(own_shade))
302 );
303 }
304
305 #[test]
308 fn a_transparent_shadow_or_a_flat_panel_casts_nothing() {
309 let theme = Theme::default();
310 let clear = open().shadow_color(Color::TRANSPARENT);
311 assert!(blurred(frame(&clear, &theme).scene()).is_empty());
312 assert!(blurred(frame(&open().elevation(0.0), &theme).scene()).is_empty());
313 assert!(
314 crisp(frame(&open().elevation(0.0), &theme).scene())
315 .iter()
316 .all(|r| r.color != theme.scheme.shadow.with_alpha(0.30)),
317 "not even an unblurred box of it"
318 );
319
320 let mut themed = Theme::default();
321 themed.widgets.menu.shadow_color = Some(Color::TRANSPARENT);
322 assert!(blurred(frame(&open(), &themed).scene()).is_empty());
323 }
324
325 #[test]
333 fn the_builders_can_be_written_in_any_order() {
334 let theme = Theme::default();
335 let colour = Color::rgb(0.1, 0.7, 0.3);
336 type Builder = fn(MenuAnchor<Msg>) -> MenuAnchor<Msg>;
337 let builders: [(&str, Builder); 5] = [
338 ("background", |m| m.background(Color::rgb(0.1, 0.7, 0.3))),
339 ("shape", |m| m.radius(2.0)),
340 ("elevation", |m| m.elevation(5.0)),
341 ("shadow_color", |m| {
342 m.shadow_color(Color::rgba(0.0, 0.5, 0.0, 0.5))
343 }),
344 ("menu_padding", |m| {
345 m.menu_padding(Insets::new(1.0, 2.0, 3.0, 4.0))
346 }),
347 ];
348 for (name, say) in builders {
349 let first = say(MenuAnchor::new(anchor(), true, Msg::Close)).content(
350 Container::<Msg>::new()
351 .width(100.0)
352 .height(50.0)
353 .color(MARK),
354 );
355 let last = say(open());
356 let (before, after) = (frame(&first, &theme), frame(&last, &theme));
357 assert_eq!(crisp(before.scene()), crisp(after.scene()), "{name}");
358 assert_eq!(blurred(before.scene()), blurred(after.scene()), "{name}");
359 let untold = frame(&open(), &theme);
360 assert_ne!(
361 (crisp(after.scene()), blurred(after.scene())),
362 (crisp(untold.scene()), blurred(untold.scene())),
363 "{name} after the content changed something"
364 );
365 }
366 assert!(crisp(frame(&open().background(colour), &theme).scene())
367 .iter()
368 .any(|r| r.color == colour));
369 }
370
371 #[test]
373 fn a_press_on_the_panel_does_not_close_it() {
374 let ui = frame(&open(), &Theme::default());
375 let press = |x, y| {
376 ui.hit(frus_core::Point::new(x, y))
377 .and_then(|id| ui.msg_for(id))
378 };
379 assert_eq!(press(50.0, 34.0), None, "the room above the content");
380 assert_eq!(press(390.0, 390.0), Some(Msg::Close));
381 }
382}