1use std::time::Duration;
4
5use super::placement::{self, Placement};
6use crate::event::Event;
7use crate::geometry::{Rect, Size};
8use crate::keymap::Key;
9use crate::motion::Easing;
10use crate::widget::{Axis, EventCx, Flex, Length, MeasureCx, Node, NodeMut, PaintCx, View, Widget, WidgetId};
11
12type Part<'a, Msg> = Box<dyn FnOnce(&mut View<'_, Msg>) + 'a>;
13
14pub struct Popover<'a, Msg> {
61 open: bool,
62 placement: Placement,
63 focus_inside: bool,
64 on_dismiss: Option<Msg>,
65 anchor: Option<Part<'a, Msg>>,
66 content: Option<Part<'a, Msg>>,
67}
68
69impl<'a, Msg: Clone + 'static> Popover<'a, Msg> {
70 #[must_use]
72 pub fn new(open: bool) -> Self {
73 Self { open, placement: Placement::Below, focus_inside: false, on_dismiss: None, anchor: None, content: None }
74 }
75
76 #[must_use]
78 pub fn anchor(mut self, build: impl FnOnce(&mut View<'_, Msg>) + 'a) -> Self {
79 self.anchor = Some(Box::new(build));
80 self
81 }
82
83 #[must_use]
85 pub fn content(mut self, build: impl FnOnce(&mut View<'_, Msg>) + 'a) -> Self {
86 self.content = Some(Box::new(build));
87 self
88 }
89
90 #[must_use]
92 pub fn placement(mut self, placement: Placement) -> Self {
93 self.placement = placement;
94 self
95 }
96
97 #[must_use]
100 pub fn focus_inside(mut self, focus_inside: bool) -> Self {
101 self.focus_inside = focus_inside;
102 self
103 }
104
105 #[must_use]
107 pub fn on_dismiss(mut self, message: Msg) -> Self {
108 self.on_dismiss = Some(message);
109 self
110 }
111
112 pub fn show<'v>(self, ui: &'v mut View<'_, Msg>) -> NodeMut<'v, Msg> {
114 let build = |part: Option<Part<'a, Msg>>, index: usize| {
115 let mut children = Vec::new();
116 if let Some(part) = part {
117 part(&mut ui.nested(&mut children));
118 }
119 Node::new(Flex::new(Axis::Column, children), index)
120 };
121 let mut anchor = build(self.anchor, ANCHOR);
122 anchor.layout.width = Length::Fill(1);
123 anchor.layout.height = Length::Fill(1);
124 let content = build(self.content, CONTENT);
125 ui.add(Layer {
126 parts: [anchor, content],
127 open: self.open,
128 placement: self.placement,
129 focus_inside: self.focus_inside,
130 on_dismiss: self.on_dismiss,
131 })
132 }
133}
134
135const ANCHOR: usize = 0;
136const CONTENT: usize = 1;
137
138struct Layer<Msg> {
139 parts: [Node<Msg>; 2],
140 open: bool,
141 placement: Placement,
142 focus_inside: bool,
143 on_dismiss: Option<Msg>,
144}
145
146#[derive(Debug, Default)]
147struct PopoverMemory {
148 was_open: bool,
149 opened_at: Duration,
150 just_opened: bool,
151 focus_before: Option<WidgetId>,
152 focus_was_inside: bool,
153}
154
155impl<Msg: Clone + 'static> Widget<Msg> for Layer<Msg> {
156 fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
157 cx.measure_child(&self.parts[ANCHOR], available)
158 }
159
160 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
161 cx.paint_child(&self.parts[ANCHOR], area);
162 let now = cx.now();
163 let focused = cx.focused();
164 let memory = cx.memory::<PopoverMemory>();
165 memory.just_opened = self.open && !memory.was_open;
166 if memory.just_opened {
167 memory.opened_at = now;
168 memory.focus_before = focused;
169 }
170 let closed_with_focus = !self.open && memory.was_open && memory.focus_was_inside;
171 let give_back = memory.focus_before.take_if(|_| closed_with_focus);
172 memory.was_open = self.open;
173 if !self.open {
174 memory.focus_was_inside = false;
175 }
176 if let Some(previous) = give_back.filter(|_| self.focus_inside) {
177 cx.request_focus(previous);
178 }
179 if self.open {
180 cx.request_overlay(area);
181 cx.register_dismissable();
182 }
183 }
184
185 fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
186 let style = cx.style("popover", None, &[]);
187 let padding = style.padding();
188 let background = style.text().bg.unwrap_or_else(|| cx.color("overlay"));
189 let screen = cx.clip();
190 let available = Size::new(
191 screen.width.saturating_sub(padding.horizontal()),
192 screen.height.saturating_sub(padding.vertical()),
193 );
194 let content = cx.measure_child(&self.parts[CONTENT], available);
195 let size = Size::new(
196 content.width.saturating_add(padding.horizontal()),
197 content.height.saturating_add(padding.vertical()),
198 );
199 let (full, side) = placement::place(anchor, size, screen, self.placement);
200
201 let (opened_at, just_opened) = {
203 let memory = cx.memory::<PopoverMemory>();
204 (memory.opened_at, memory.just_opened)
205 };
206 let enter = cx.env().theme().motion().enter;
207 let progress = cx.progress_since(opened_at, enter, Easing::EaseOut);
208 let shown = placement::unfold(full, side, progress);
209 cx.register_hit(shown);
210 cx.floating(shown, |cx| {
211 cx.clear(shown, background);
212 cx.with_clip(shown, |cx| cx.paint_child(&self.parts[CONTENT], full.inset(padding)));
213 });
214 let content_id = self.parts[CONTENT].id();
215 if self.focus_inside && just_opened {
216 cx.request_focus_within(content_id);
217 }
218 let inside = cx.has_focus_within();
220 cx.memory::<PopoverMemory>().focus_was_inside |= inside;
221 }
222
223 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
224 if !self.open {
225 return false;
226 }
227 let dismiss = match event {
228 Event::PointerOutside => true,
229 Event::Key(key) => key.is_plain(Key::Esc),
230 _ => false,
231 };
232 if dismiss && let Some(message) = &self.on_dismiss {
233 cx.emit(message.clone());
234 }
235 dismiss
236 }
237
238 fn children(&self) -> &[Node<Msg>] {
239 &self.parts
240 }
241
242 fn children_mut(&mut self) -> &mut [Node<Msg>] {
243 &mut self.parts
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use crate::runtime::{App, Command, Harness};
251 use crate::widgets::{Button, Text, TextInput};
252
253 struct Demo {
254 open: bool,
255 focus_inside: bool,
256 placement: Placement,
257 name: String,
258 dismissed: u32,
259 other: u32,
260 }
261
262 #[derive(Clone)]
263 enum Msg {
264 Toggle,
265 Dismiss,
266 Name(String),
267 Other,
268 }
269
270 impl App for Demo {
271 type Msg = Msg;
272 fn update(&mut self, msg: Msg) -> Command<Msg> {
273 match msg {
274 Msg::Toggle => self.open = !self.open,
275 Msg::Dismiss => {
276 self.open = false;
277 self.dismissed += 1;
278 }
279 Msg::Name(name) => self.name = name,
280 Msg::Other => self.other += 1,
281 }
282 Command::none()
283 }
284 fn view(&self, ui: &mut View<'_, Msg>) {
285 ui.column(|ui| {
286 ui.row(|ui| {
287 Popover::new(self.open)
288 .placement(self.placement)
289 .focus_inside(self.focus_inside)
290 .on_dismiss(Msg::Dismiss)
291 .anchor(|ui| {
292 ui.add(Button::new("Filters").on_press(Msg::Toggle)).id("filters");
293 })
294 .content(|ui| {
295 ui.add(Text::new("Status"));
296 ui.add(TextInput::new(&self.name).on_change(Msg::Name)).width(Length::Cells(10)).id("name");
297 })
298 .show(ui);
299 ui.add(Button::new("Other").on_press(Msg::Other)).id("other");
300 })
301 .gap(1);
302 ui.add(Text::new("content under the layer")).selectable(true);
303 });
304 }
305 }
306
307 fn demo() -> Demo {
308 Demo {
309 open: false,
310 focus_inside: false,
311 placement: Placement::Below,
312 name: String::new(),
313 dismissed: 0,
314 other: 0,
315 }
316 }
317
318 #[test]
319 fn opens_below_unfolding_on_the_overlay_surface() {
320 let mut h = Harness::new(demo(), 40, 8);
321 h.click_text("Filters");
322 assert!(h.app().open);
323 let first = h.screen();
324 assert!(!first.contains("Status"), "the layer starts folded: {first}");
325 h.advance(Duration::from_millis(200));
326 assert_eq!(h.screen(), "▌ Filters Other\n the layer\n Status\n ❯\n\n\n\n\n");
328 let overlay = h.env().theme().color("overlay");
329 assert_eq!(h.bg(0, 1), overlay);
330 assert_eq!(h.bg(13, 4), overlay);
331 }
332
333 #[test]
334 fn escape_and_outside_press_dismiss_and_the_press_still_reaches_its_target() {
335 let mut h = Harness::new(Demo { open: true, ..demo() }, 40, 8);
336 h.press("esc");
337 assert_eq!((h.app().open, h.app().dismissed), (false, 1));
338 h.click_text("Filters").advance(Duration::from_millis(200));
339 h.click_text("Other");
340 assert_eq!((h.app().open, h.app().dismissed, h.app().other), (false, 2, 1), "one press closes and acts");
341 }
342
343 #[test]
344 fn a_press_that_closes_the_layer_follows_the_text_selection_rules_of_its_cell() {
345 let mut closed = Harness::new(demo(), 40, 8);
346 closed.drag((16, 1), (30, 1)).press("ctrl+c");
347 assert!(closed.clipboard().is_some_and(|text| text.contains("layer")), "{:?}", closed.clipboard());
348 let mut h = Harness::new(Demo { open: true, ..demo() }, 40, 8);
349 h.advance(Duration::from_millis(200));
350 h.drag((16, 1), (30, 1)).press("ctrl+c");
351 assert_eq!((h.app().open, h.app().dismissed), (false, 1));
352 assert_eq!(h.clipboard(), closed.clipboard(), "the same drag without a layer selects the same");
353 }
354
355 #[test]
356 fn clicking_inside_the_layer_keeps_it_open_and_the_anchor_toggles() {
357 let mut h = Harness::new(Demo { open: true, ..demo() }, 40, 8);
358 h.advance(Duration::from_millis(200));
359 h.click_text("Status");
360 assert!(h.app().open);
361 h.click_text("Filters");
362 assert_eq!((h.app().open, h.app().dismissed), (false, 0));
363 }
364
365 #[test]
366 fn focus_moves_inside_and_comes_back() {
367 let mut h = Harness::new(Demo { focus_inside: true, ..demo() }, 40, 8);
368 h.press("tab");
369 assert!(h.is_focused("filters"));
370 h.press("enter");
371 assert!(h.is_focused("name"));
372 h.type_text("web");
373 assert_eq!(h.app().name, "web");
374 h.press("esc");
375 assert!(!h.app().open);
376 assert!(h.is_focused("filters"));
377 }
378
379 #[test]
380 fn flips_above_near_the_bottom_and_opens_at_once_with_reduced_motion() {
381 struct Bottom(bool);
382 impl App for Bottom {
383 type Msg = ();
384 fn update(&mut self, (): ()) -> Command<()> {
385 Command::none()
386 }
387 fn view(&self, ui: &mut View<'_, ()>) {
388 ui.column(|ui| {
389 ui.spacer();
390 Popover::new(self.0)
391 .anchor(|ui| {
392 ui.add(Text::new("anchor"));
393 })
394 .content(|ui| {
395 ui.add(Text::new("menu"));
396 })
397 .show(ui);
398 })
399 .fill();
400 }
401 }
402 let mut h = Harness::new(Bottom(true), 20, 6);
403 h.set_reduced_motion(true);
404 assert_eq!(h.screen(), "\n\n\n menu\n\nanchor\n");
405 }
406
407 #[test]
408 fn side_placement_sits_to_the_right() {
409 let mut h = Harness::new(Demo { open: true, placement: Placement::Right, ..demo() }, 60, 8);
410 h.advance(Duration::from_millis(200));
411 let screen = h.screen();
412 assert!(screen.lines().nth(1).is_some_and(|line| line.starts_with("content und Status")), "{screen}");
413 assert_eq!(h.bg(11, 0), h.env().theme().color("overlay"));
414 }
415}