1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
//! [`OverlayPortal`]: shows content **above** everything else (out of the layout flow,
//! not clipped by parents). The basis for floating menus, tooltips and modals.
use frus_core::{Rect, Scene};
use frus_layout::Style;
use crate::interaction::Status;
use crate::theme::Theme;
use crate::widget::Widget;
/// Where to place the overlay relative to its anchor, or to the window.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Placement {
/// Just below the anchor (a dropdown menu).
Below,
/// Centred in the window, with a scrim (a modal).
Center,
/// Above the anchor, **only while the anchor is hovered** (a tooltip).
Tooltip,
/// A full-height panel against the **left** edge of the window, with a
/// scrim (a side drawer). See [`crate::Drawer`].
Left,
/// A full-height panel against the **right** edge of the window, with a
/// scrim (a side drawer). See [`crate::Drawer`].
Right,
/// A full-width panel against the **bottom** edge of the window, sliding up,
/// with a scrim (a modal sheet). See [`crate::BottomSheet`].
Bottom,
/// Centred on the anchor and **above** it, or below it — clear of the handles that hang
/// there — when there is no room above; held inside the window. The bar over a text
/// selection (see [`crate::ToolbarItem`]), whose anchor is the selection's box rather
/// than a widget's.
Selection,
/// **Beside** the anchor, on its end side, tops level: a submenu opening from its row
/// (milestone 603). Where the window has no room there, it opens on the start side.
Beside,
}
/// A portal: an **anchor** (in the flow) and an optional floating **overlay**.
pub struct OverlayPortal<Msg = crate::callback::Callback> {
/// `[anchor]` or `[anchor, overlay]`.
children: Vec<Box<dyn Widget<Msg>>>,
placement: Placement,
/// Message emitted when the scrim is clicked (a modal), to close it.
on_dismiss: Option<Msg>,
}
impl<Msg: 'static> OverlayPortal<Msg> {
/// Creates a portal around an anchor, which is rendered normally.
pub fn new(anchor: impl Widget<Msg> + 'static) -> Self {
Self::new_boxed(Box::new(anchor))
}
/// [`Self::new`], for an anchor already boxed — what a widget that *assembles* its
/// anchor has, rather than an `impl Widget` a caller typed out.
pub fn new_boxed(anchor: Box<dyn Widget<Msg>>) -> Self {
Self {
children: vec![Box::new(anchor)],
placement: Placement::Below,
on_dismiss: None,
}
}
/// Adds the floating content and its placement.
pub fn overlay(self, content: impl Widget<Msg> + 'static, placement: Placement) -> Self {
self.overlay_boxed(Box::new(content), placement)
}
/// [`Self::overlay`], for content already boxed.
pub fn overlay_boxed(mut self, content: Box<dyn Widget<Msg>>, placement: Placement) -> Self {
self.children.truncate(1);
self.children.push(Box::new(content));
self.placement = placement;
self
}
/// Message emitted on a click **outside** the content (on the scrim) — to close.
pub fn dismiss(mut self, message: impl Into<Msg>) -> Self {
self.on_dismiss = Some(message.into());
self
}
}
impl<Msg: Clone> Widget<Msg> for OverlayPortal<Msg> {
/// A column, so that the anchor is as wide as the portal is given: with nothing floating,
/// the portal is laid out as any container is, and in a row the anchor kept its own
/// width. (With something floating, the walk picks the axis from what the anchor fills,
/// milestone 607.)
fn style(&self) -> Style {
Style {
flex_direction: frus_layout::FlexDirection::Column,
..Style::default()
}
}
fn children(&self) -> &[Box<dyn Widget<Msg>>] {
&self.children
}
fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
fn on_click(&self) -> Option<Msg> {
None
}
fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
self.children
.get(1)
.map(|content| (content.as_ref(), self.placement))
}
fn overlay_dismiss(&self) -> Option<Msg> {
self.on_dismiss.clone()
}
/// `Escape` closes the portal (if a dismiss message is configured) — consumed
/// while bubbling leaf→root when the focus is inside.
fn on_key(&self, key: &crate::Key) -> crate::KeyResponse<Msg> {
match (key, &self.on_dismiss) {
(crate::Key::Escape, Some(message)) => {
crate::KeyResponse::Handled(Some(message.clone()))
}
_ => crate::KeyResponse::Ignored,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Container, Runtime, Size, Text};
#[test]
fn overlay_present_only_when_set() {
let bare: OverlayPortal<()> = OverlayPortal::new(Container::<()>::new());
assert!(Widget::<()>::overlay(&bare).is_none());
let with: OverlayPortal<()> =
OverlayPortal::new(Container::<()>::new()).overlay(Text::new("tip"), Placement::Center);
assert!(Widget::<()>::overlay(&with).is_some());
}
#[test]
fn center_overlay_draws_scrim_and_content() {
// A Center overlay draws a full-screen scrim + the content on top.
let portal: OverlayPortal<()> =
OverlayPortal::new(Container::<()>::new().width(20.0).height(20.0)).overlay(
Container::<()>::new()
.width(100.0)
.height(60.0)
.color(frus_core::Color::WHITE),
Placement::Center,
);
let ui = crate::build_ui(
&portal,
Size::new(400.0, 300.0),
&Runtime::default(),
&crate::Theme::default(),
);
// At least: the scrim (full screen) + the overlay's content.
let full_screen =
ui.scene().primitives().iter().any(
|p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.width >= 400.0),
);
assert!(full_screen, "the full-screen scrim must be present");
}
/// **A press on an overlay is the overlay's** (milestone 642): on its box, and — under
/// one that a press outside dismisses — anywhere in the window. What the shell does to
/// the pages under it, the back gesture, does not start there.
#[test]
fn an_overlay_says_where_presses_are_its_own() {
let anchored = |dismiss: bool| {
let portal = OverlayPortal::<()>::new(Container::<()>::new().width(20.0).height(20.0))
.overlay(
Container::<()>::new().width(100.0).height(60.0),
Placement::Below,
);
let portal = if dismiss { portal.dismiss(()) } else { portal };
crate::build_ui(
&portal,
Size::new(400.0, 300.0),
&Runtime::default(),
&crate::Theme::default(),
)
};
let open = anchored(false);
assert!(
open.over_overlay(frus_core::Point::new(10.0, 40.0)),
"on the overlay's box"
);
assert!(
!open.over_overlay(frus_core::Point::new(300.0, 250.0)),
"elsewhere, the pages'"
);
let barred = anchored(true);
assert!(
barred.over_overlay(frus_core::Point::new(300.0, 250.0)),
"anywhere, under a barrier"
);
}
/// An anchored overlay is nudged back inside the window when it overflows an edge —
/// that is for a menu opened near the right margin, and it assumes the window is
/// showing the anchor. When the anchor has **left** the window the nudge does the
/// opposite of its job: it pulls a menu into view that belongs to something nobody
/// can see, and leaves a window-wide dismissal barrier behind it that swallows the
/// next press anywhere.
#[test]
fn an_overlay_whose_anchor_left_the_window_goes_with_it() {
// A row far wider than the window puts the portal's anchor past the right edge.
let row: crate::Flex<()> = crate::Flex::row()
.child(Container::<()>::new().width(900.0).height(20.0))
.child(
OverlayPortal::new(Container::<()>::new().width(20.0).height(20.0))
.overlay(
Container::<()>::new()
.width(120.0)
.height(40.0)
.color(frus_core::Color::WHITE),
Placement::Below,
)
.dismiss(()),
);
let ui = crate::build_ui(
&row,
Size::new(400.0, 300.0),
&Runtime::default(),
&crate::Theme::default(),
);
// The overlay's own 120x40 panel must not have been dragged back on screen.
let inside = ui.scene().primitives().iter().any(|p| {
matches!(p, frus_core::Primitive::Rect { rect, .. }
if (rect.width - 120.0).abs() < 0.5 && rect.x < 400.0)
});
assert!(!inside, "the overlay was pulled back into the window");
// And no window-wide dismissal barrier was left behind — `top_dismiss` is what
// Escape and an outside press both resolve through.
assert!(
ui.top_dismiss().is_none(),
"an invisible barrier would eat the next press"
);
}
}