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> {
65 open: bool,
66 placement: Placement,
67 focus_inside: bool,
68 match_anchor_width: bool,
69 on_dismiss: Option<Msg>,
70 anchor: Option<Part<'a, Msg>>,
71 content: Option<Part<'a, Msg>>,
72}
73
74impl<'a, Msg: Clone + 'static> Popover<'a, Msg> {
75 #[must_use]
77 pub fn new(open: bool) -> Self {
78 Self {
79 open,
80 placement: Placement::Below,
81 focus_inside: false,
82 match_anchor_width: false,
83 on_dismiss: None,
84 anchor: None,
85 content: None,
86 }
87 }
88
89 #[must_use]
91 pub fn anchor(mut self, build: impl FnOnce(&mut View<'_, Msg>) + 'a) -> Self {
92 self.anchor = Some(Box::new(build));
93 self
94 }
95
96 #[must_use]
98 pub fn content(mut self, build: impl FnOnce(&mut View<'_, Msg>) + 'a) -> Self {
99 self.content = Some(Box::new(build));
100 self
101 }
102
103 #[must_use]
105 pub fn placement(mut self, placement: Placement) -> Self {
106 self.placement = placement;
107 self
108 }
109
110 #[must_use]
113 pub fn focus_inside(mut self, focus_inside: bool) -> Self {
114 self.focus_inside = focus_inside;
115 self
116 }
117
118 #[must_use]
123 pub fn match_anchor_width(mut self, match_anchor_width: bool) -> Self {
124 self.match_anchor_width = match_anchor_width;
125 self
126 }
127
128 #[must_use]
130 pub fn on_dismiss(mut self, message: Msg) -> Self {
131 self.on_dismiss = Some(message);
132 self
133 }
134
135 pub fn show<'v>(self, ui: &'v mut View<'_, Msg>) -> NodeMut<'v, Msg> {
137 let build = |part: Option<Part<'a, Msg>>, index: usize| {
138 let mut children = Vec::new();
139 if let Some(part) = part {
140 part(&mut ui.nested(&mut children));
141 }
142 Node::new(Flex::new(Axis::Column, children), index)
143 };
144 let mut anchor = build(self.anchor, ANCHOR);
145 anchor.layout.width = Length::Fill(1);
146 anchor.layout.height = Length::Fill(1);
147 let content = build(self.content, CONTENT);
148 ui.add(Layer {
149 parts: [anchor, content],
150 open: self.open,
151 placement: self.placement,
152 focus_inside: self.focus_inside,
153 match_anchor_width: self.match_anchor_width,
154 on_dismiss: self.on_dismiss,
155 })
156 }
157}
158
159const ANCHOR: usize = 0;
160const CONTENT: usize = 1;
161
162struct Layer<Msg> {
163 parts: [Node<Msg>; 2],
164 open: bool,
165 placement: Placement,
166 focus_inside: bool,
167 match_anchor_width: bool,
168 on_dismiss: Option<Msg>,
169}
170
171#[derive(Debug, Default)]
172struct PopoverMemory {
173 was_open: bool,
174 opened_at: Duration,
175 just_opened: bool,
176 focus_before: Option<WidgetId>,
177 focus_was_inside: bool,
178}
179
180impl<Msg: Clone + 'static> Widget<Msg> for Layer<Msg> {
181 fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
182 cx.measure_child(&self.parts[ANCHOR], available)
183 }
184
185 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
186 cx.paint_child(&self.parts[ANCHOR], area);
187 let now = cx.now();
188 let focused = cx.focused();
189 let memory = cx.memory::<PopoverMemory>();
190 memory.just_opened = self.open && !memory.was_open;
191 if memory.just_opened {
192 memory.opened_at = now;
193 memory.focus_before = focused;
194 }
195 let closed_with_focus = !self.open && memory.was_open && memory.focus_was_inside;
196 let give_back = memory.focus_before.take_if(|_| closed_with_focus);
197 memory.was_open = self.open;
198 if !self.open {
199 memory.focus_was_inside = false;
200 }
201 if let Some(previous) = give_back.filter(|_| self.focus_inside) {
202 cx.request_focus(previous);
203 }
204 if self.open {
205 cx.request_overlay(area);
206 cx.register_dismissable();
207 }
208 }
209
210 fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
211 let style = cx.style("popover", None, &[]);
212 let padding = style.padding();
213 let background = style.text().bg.unwrap_or_else(|| cx.color("overlay"));
214 let screen = cx.clip();
215 let available = Size::new(
216 screen.width.saturating_sub(padding.horizontal()),
217 screen.height.saturating_sub(padding.vertical()),
218 );
219 let matched = self.match_anchor_width.then(|| placement::anchor_width(anchor, screen));
220 let inner = match matched {
223 Some(width) => Size::new(width.saturating_sub(padding.horizontal()), available.height),
224 None => available,
225 };
226 let content = cx.measure_child(&self.parts[CONTENT], inner);
227 let size = Size::new(
228 matched.unwrap_or_else(|| content.width.saturating_add(padding.horizontal())),
229 content.height.saturating_add(padding.vertical()),
230 );
231 let (full, side) = placement::place(anchor, size, screen, self.placement);
232
233 let (opened_at, just_opened) = {
235 let memory = cx.memory::<PopoverMemory>();
236 (memory.opened_at, memory.just_opened)
237 };
238 let enter = cx.env().theme().motion().enter;
239 let progress = cx.progress_since(opened_at, enter, Easing::EaseOut);
240 let shown = placement::unfold(full, side, progress);
241 cx.register_hit(shown);
242 cx.floating(shown, |cx| {
243 cx.clear(shown, background);
244 cx.with_clip(shown, |cx| cx.paint_child(&self.parts[CONTENT], full.inset(padding)));
245 });
246 let content_id = self.parts[CONTENT].id();
247 if self.focus_inside && just_opened {
248 cx.request_focus_within(content_id);
249 }
250 let inside = cx.has_focus_within();
252 cx.memory::<PopoverMemory>().focus_was_inside |= inside;
253 }
254
255 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
256 if !self.open {
257 return false;
258 }
259 let dismiss = match event {
260 Event::PointerOutside => true,
261 Event::Key(key) => key.is_plain(Key::Esc),
262 _ => false,
263 };
264 if dismiss && let Some(message) = &self.on_dismiss {
265 cx.emit(message.clone());
266 }
267 dismiss
268 }
269
270 fn children(&self) -> &[Node<Msg>] {
271 &self.parts
272 }
273
274 fn children_mut(&mut self) -> &mut [Node<Msg>] {
275 &mut self.parts
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use crate::runtime::{App, Command, Harness};
283 use crate::widgets::{Button, Text, TextInput};
284
285 struct Demo {
286 open: bool,
287 focus_inside: bool,
288 placement: Placement,
289 name: String,
290 dismissed: u32,
291 other: u32,
292 }
293
294 #[derive(Clone)]
295 enum Msg {
296 Toggle,
297 Dismiss,
298 Name(String),
299 Other,
300 }
301
302 impl App for Demo {
303 type Msg = Msg;
304 fn update(&mut self, msg: Msg) -> Command<Msg> {
305 match msg {
306 Msg::Toggle => self.open = !self.open,
307 Msg::Dismiss => {
308 self.open = false;
309 self.dismissed += 1;
310 }
311 Msg::Name(name) => self.name = name,
312 Msg::Other => self.other += 1,
313 }
314 Command::none()
315 }
316 fn view(&self, ui: &mut View<'_, Msg>) {
317 ui.column(|ui| {
318 ui.row(|ui| {
319 Popover::new(self.open)
320 .placement(self.placement)
321 .focus_inside(self.focus_inside)
322 .on_dismiss(Msg::Dismiss)
323 .anchor(|ui| {
324 ui.add(Button::new("Filters").on_press(Msg::Toggle)).id("filters");
325 })
326 .content(|ui| {
327 ui.add(Text::new("Status"));
328 ui.add(TextInput::new(&self.name).on_change(Msg::Name)).width(Length::Cells(10)).id("name");
329 })
330 .show(ui);
331 ui.add(Button::new("Other").on_press(Msg::Other)).id("other");
332 })
333 .gap(1);
334 ui.add(Text::new("content under the layer")).selectable(true);
335 });
336 }
337 }
338
339 fn demo() -> Demo {
340 Demo {
341 open: false,
342 focus_inside: false,
343 placement: Placement::Below,
344 name: String::new(),
345 dismissed: 0,
346 other: 0,
347 }
348 }
349
350 #[test]
351 fn opens_below_unfolding_on_the_overlay_surface() {
352 let mut h = Harness::new(demo(), 40, 8);
353 h.click_text("Filters");
354 assert!(h.app().open);
355 let first = h.screen();
356 assert!(!first.contains("Status"), "the layer starts folded: {first}");
357 h.advance(Duration::from_millis(200));
358 assert_eq!(h.screen(), "▌ Filters Other\n the layer\n Status\n ❯\n\n\n\n\n");
360 let overlay = h.env().theme().color("overlay");
361 assert_eq!(h.bg(0, 1), overlay);
362 assert_eq!(h.bg(13, 4), overlay);
363 }
364
365 #[test]
366 fn escape_and_outside_press_dismiss_and_the_press_still_reaches_its_target() {
367 let mut h = Harness::new(Demo { open: true, ..demo() }, 40, 8);
368 h.press("esc");
369 assert_eq!((h.app().open, h.app().dismissed), (false, 1));
370 h.click_text("Filters").advance(Duration::from_millis(200));
371 h.click_text("Other");
372 assert_eq!((h.app().open, h.app().dismissed, h.app().other), (false, 2, 1), "one press closes and acts");
373 }
374
375 #[test]
376 fn a_press_that_closes_the_layer_follows_the_text_selection_rules_of_its_cell() {
377 let mut closed = Harness::new(demo(), 40, 8);
378 closed.drag((16, 1), (30, 1)).press("ctrl+c");
379 assert!(closed.clipboard().is_some_and(|text| text.contains("layer")), "{:?}", closed.clipboard());
380 let mut h = Harness::new(Demo { open: true, ..demo() }, 40, 8);
381 h.advance(Duration::from_millis(200));
382 h.drag((16, 1), (30, 1)).press("ctrl+c");
383 assert_eq!((h.app().open, h.app().dismissed), (false, 1));
384 assert_eq!(h.clipboard(), closed.clipboard(), "the same drag without a layer selects the same");
385 }
386
387 #[test]
388 fn clicking_inside_the_layer_keeps_it_open_and_the_anchor_toggles() {
389 let mut h = Harness::new(Demo { open: true, ..demo() }, 40, 8);
390 h.advance(Duration::from_millis(200));
391 h.click_text("Status");
392 assert!(h.app().open);
393 h.click_text("Filters");
394 assert_eq!((h.app().open, h.app().dismissed), (false, 0));
395 }
396
397 #[test]
398 fn focus_moves_inside_and_comes_back() {
399 let mut h = Harness::new(Demo { focus_inside: true, ..demo() }, 40, 8);
400 h.press("tab");
401 assert!(h.is_focused("filters"));
402 h.press("enter");
403 assert!(h.is_focused("name"));
404 h.type_text("web");
405 assert_eq!(h.app().name, "web");
406 h.press("esc");
407 assert!(!h.app().open);
408 assert!(h.is_focused("filters"));
409 }
410
411 #[test]
412 fn flips_above_near_the_bottom_and_opens_at_once_with_reduced_motion() {
413 struct Bottom(bool);
414 impl App for Bottom {
415 type Msg = ();
416 fn update(&mut self, (): ()) -> Command<()> {
417 Command::none()
418 }
419 fn view(&self, ui: &mut View<'_, ()>) {
420 ui.column(|ui| {
421 ui.spacer();
422 Popover::new(self.0)
423 .anchor(|ui| {
424 ui.add(Text::new("anchor"));
425 })
426 .content(|ui| {
427 ui.add(Text::new("menu"));
428 })
429 .show(ui);
430 })
431 .fill();
432 }
433 }
434 let mut h = Harness::new(Bottom(true), 20, 6);
435 h.set_reduced_motion(true);
436 assert_eq!(h.screen(), "\n\n\n menu\n\nanchor\n");
437 }
438
439 #[test]
440 fn side_placement_sits_to_the_right() {
441 let mut h = Harness::new(Demo { open: true, placement: Placement::Right, ..demo() }, 60, 8);
442 h.advance(Duration::from_millis(200));
443 let screen = h.screen();
444 assert!(screen.lines().nth(1).is_some_and(|line| line.starts_with("content und Status")), "{screen}");
445 assert_eq!(h.bg(11, 0), h.env().theme().color("overlay"));
446 }
447
448 struct Anchored {
450 width: u16,
451 matched: bool,
452 }
453
454 impl App for Anchored {
455 type Msg = ();
456 fn update(&mut self, (): ()) -> Command<()> {
457 Command::none()
458 }
459 fn view(&self, ui: &mut View<'_, ()>) {
460 Popover::new(true)
461 .match_anchor_width(self.matched)
462 .anchor(|ui| {
463 ui.add(Text::new("anchor"));
464 })
465 .content(|ui| {
466 ui.add(Text::new("a line far wider than its anchor"));
467 })
468 .show(ui)
469 .width(Length::Cells(self.width));
470 }
471 }
472
473 fn span(h: &Harness<Anchored>, y: u16) -> Option<(u16, u16)> {
476 let canvas = h.env().theme().color("canvas");
477 let columns: Vec<u16> = (0..40u16).filter(|x| h.bg(*x, y) != canvas).collect();
478 columns.first().zip(columns.last()).map(|(first, last)| (*first, *last))
479 }
480
481 #[test]
482 fn the_layer_is_as_wide_as_its_anchor_whatever_the_content_wants() {
483 for width in [14, 30] {
484 let mut h = Harness::new(Anchored { width, matched: true }, 40, 8);
485 h.set_reduced_motion(true);
486 let screen = h.screen();
487 assert_eq!(span(&h, 1), Some((0, width - 1)), "from the anchor's left edge to its right one:\n{screen}");
488
489 let mut h = Harness::new(Anchored { width, matched: false }, 40, 8);
490 h.set_reduced_motion(true);
491 let screen = h.screen();
492 assert!(
493 span(&h, 1).is_some_and(|(_, last)| last > width - 1),
494 "without the option the content decides:\n{screen}"
495 );
496 }
497 let mut h = Harness::new(Anchored { width: 50, matched: true }, 40, 8);
498 h.set_reduced_motion(true);
499 assert_eq!(span(&h, 1), Some((0, 39)), "an anchor wider than the screen is cut to it:\n{}", h.screen());
500 }
501}