1#![forbid(unsafe_code)]
2
3use std::cell::RefCell;
4use std::collections::HashMap;
5use std::rc::Rc;
6use std::time::Instant;
7
8use sequoia_core::{Color, Edges, Length};
9use sequoia_layout::LayoutStyle;
10use sequoia_reactive::Signal;
11use sequoia_style::{Backdrop, BoxStyle, ModalStyle, ShadowSpec, Theme};
12use sequoia_view::{
13 Anchor, AnyView, Dismiss, Easing, InputMode, OverlayEntry, OverlayId, OverlayLayer,
14 OverlayOverflow, PaintCtx, Placement, Presence, Unit, View,
15};
16
17thread_local! {
18 static ENTRIES: RefCell<HashMap<usize, OverlayId>> = RefCell::new(HashMap::new());
19}
20
21fn get_entry(key: usize) -> Option<OverlayId> {
22 ENTRIES.with(|m| m.borrow().get(&key).copied())
23}
24
25fn insert_entry(key: usize, id: OverlayId) {
26 ENTRIES.with(|m| {
27 m.borrow_mut().insert(key, id);
28 });
29}
30
31fn take_entry(key: usize) -> Option<OverlayId> {
32 ENTRIES.with(|m| m.borrow_mut().remove(&key))
33}
34
35thread_local! {
36 static SCRIM_CLOCKS: RefCell<HashMap<usize, Rc<RefCell<Option<Instant>>>>> =
37 RefCell::new(HashMap::new());
38}
39
40fn scrim_clock(open_id: usize) -> Rc<RefCell<Option<Instant>>> {
41 SCRIM_CLOCKS.with(|m| m.borrow_mut().entry(open_id).or_default().clone())
42}
43
44fn forget_scrim_clock(open_id: usize) {
45 SCRIM_CLOCKS.with(|m| {
46 m.borrow_mut().remove(&open_id);
47 });
48}
49
50pub const DEFAULT_SCRIM: Color = Color::rgba(0.0, 0.0, 0.0, 0.45);
51
52pub(crate) const MODAL_ENTER_SECS: f32 = 0.22;
53const MODAL_ENTER_EASE: Easing = Easing::CubicBezier(0.3, 1.3, 0.4, 1.0);
54const MODAL_ENTER_DY: f32 = -8.0;
55
56pub(crate) const SCRIM_FADE_SECS: f32 = 0.18;
57
58struct ScrimFill {
59 scrim: Color,
60 backdrop: Backdrop,
61 open_id: usize,
62 fade: Rc<RefCell<Option<Instant>>>,
63}
64
65fn fade_in_opacity(clock: &Rc<RefCell<Option<Instant>>>, secs: f32) -> (f32, bool) {
66 let now = Instant::now();
67 let start = *clock.borrow_mut().get_or_insert(now);
68 let el = now.duration_since(start).as_secs_f32();
69 if el >= secs {
70 (1.0, false)
71 } else {
72 (el / secs.max(f32::MIN_POSITIVE), true)
73 }
74}
75
76impl View for ScrimFill {
77 fn body(&self) -> impl View + 'static {
78 Unit
79 }
80 fn is_leaf(&self) -> bool {
81 true
82 }
83 fn layout_style(&self) -> LayoutStyle {
84 LayoutStyle {
85 width: Length::Fill(1.0),
86 height: Length::Fill(1.0),
87 ..LayoutStyle::default()
88 }
89 }
90 fn hit_testable(&self) -> bool {
91 false
92 }
93 fn on_unmount(&self) {
94 forget_scrim_clock(self.open_id);
95 }
96 fn paint_opacity(&self) -> Option<f32> {
97 Some(fade_in_opacity(&self.fade, SCRIM_FADE_SECS).0)
98 }
99 fn paint_self(&self, ctx: &mut PaintCtx) {
100 ctx.push_backdrop(ctx.rect, self.backdrop);
101 if self.scrim.a > 0.0 {
102 ctx.quad(ctx.rect, self.scrim, 0.0);
103 }
104 if fade_in_opacity(&self.fade, SCRIM_FADE_SECS).1 {
105 ctx.request_frame();
106 }
107 }
108}
109
110fn default_shadow() -> ShadowSpec {
111 ShadowSpec::new(Color::BLACK.with_alpha(0.35), 24.0).offset(0.0, 8.0)
112}
113
114pub(crate) struct OverlayPanel {
115 pub child: AnyView,
116 pub width: Length,
117 pub height: Length,
118 pub padding: Edges,
119 pub background: Color,
120 pub border: Color,
121 pub border_width: f32,
122 pub radius: f32,
123 pub backdrop: Backdrop,
124}
125
126impl View for OverlayPanel {
127 fn body(&self) -> impl View + 'static {
128 Unit
129 }
130 fn children_override(&self) -> Option<Vec<AnyView>> {
131 Some(vec![self.child.clone()])
132 }
133 fn layout_style(&self) -> LayoutStyle {
134 LayoutStyle {
135 width: self.width,
136 height: self.height,
137 padding: self.padding,
138 ..LayoutStyle::default()
139 }
140 }
141 fn paint_self(&self, ctx: &mut PaintCtx) {
142 let radius = self.radius.max(0.0);
143 ctx.push_backdrop(
144 ctx.rect,
145 Backdrop {
146 radius,
147 ..self.backdrop
148 },
149 );
150 ctx.paint_box(
151 BoxStyle::new()
152 .background(self.background)
153 .border(self.border_width.max(0.0), self.border)
154 .radius(radius)
155 .shadow(default_shadow()),
156 );
157 }
158}
159
160#[derive(Clone)]
161pub struct ModalScrim {
162 open: Signal<bool>,
163 scrim: Color,
164 backdrop: Backdrop,
165}
166
167impl ModalScrim {
168 pub fn new(open: Signal<bool>) -> Self {
169 Self {
170 open,
171 scrim: DEFAULT_SCRIM,
172 backdrop: Backdrop::NONE,
173 }
174 }
175
176 pub fn scrim_color(mut self, color: Color) -> Self {
177 self.scrim = color;
178 self
179 }
180
181 pub fn backdrop(mut self, backdrop: Backdrop) -> Self {
182 self.backdrop = backdrop;
183 self
184 }
185
186 fn is_open(&self) -> bool {
187 self.open.get_untracked()
188 }
189}
190
191impl View for ModalScrim {
192 fn body(&self) -> impl View + 'static {
193 Unit
194 }
195 fn keyed_children(&self) -> Option<Vec<(u64, AnyView)>> {
196 let open = self.open.get();
197 if !open {
198 return Some(Vec::new());
199 }
200 Some(vec![(
201 0,
202 AnyView::new(ScrimFill {
203 scrim: self.scrim,
204 backdrop: Backdrop {
205 radius: 0.0,
206 ..self.backdrop
207 },
208 open_id: self.open.id(),
209
210 fade: scrim_clock(self.open.id()),
211 }),
212 )])
213 }
214 fn layout_style(&self) -> LayoutStyle {
215 if self.is_open() {
216 LayoutStyle {
217 width: Length::Fill(1.0),
218 height: Length::Fill(1.0),
219 ..LayoutStyle::default()
220 }
221 } else {
222 LayoutStyle {
223 width: Length::Fit,
224 height: Length::Fit,
225 ..LayoutStyle::default()
226 }
227 }
228 }
229 fn hit_testable(&self) -> bool {
230 false
231 }
232}
233
234pub struct Modal {
235 open: Signal<bool>,
236 child: Option<AnyView>,
237 width: Length,
238 height: Length,
239 padding: Edges,
240 style: Option<ModalStyle>,
241 background: Option<Color>,
242 border: Option<Color>,
243 border_width: Option<f32>,
244 radius: Option<f32>,
245 backdrop: Option<Backdrop>,
246 scrim: Option<Color>,
247 scrim_backdrop: Option<Backdrop>,
248 dismiss: Dismiss,
249 margin: Edges,
250}
251
252impl Modal {
253 pub fn new(open: Signal<bool>) -> Self {
254 Self {
255 open,
256 child: None,
257 width: Length::Fit,
258 height: Length::Fit,
259 padding: Edges::ZERO,
260 style: None,
261 background: None,
262 border: None,
263 border_width: None,
264 radius: None,
265 backdrop: None,
266 scrim: None,
267 scrim_backdrop: None,
268 dismiss: Dismiss::OutsideClick,
269 margin: Edges::ZERO,
270 }
271 }
272
273 pub fn child<V: View + 'static>(mut self, child: V) -> Self {
274 self.child = Some(AnyView::new(child));
275 self
276 }
277
278 pub fn child_if<V: View + 'static>(mut self, cond: bool, make: impl FnOnce() -> V) -> Self {
279 if cond {
280 self.child = Some(AnyView::new(make()));
281 }
282 self
283 }
284
285 pub fn child_opt<V: View + 'static>(mut self, child: Option<V>) -> Self {
286 if let Some(v) = child {
287 self.child = Some(AnyView::new(v));
288 }
289 self
290 }
291
292 pub fn width(mut self, width: impl Into<Length>) -> Self {
293 self.width = width.into();
294 self
295 }
296
297 pub fn height(mut self, height: impl Into<Length>) -> Self {
298 self.height = height.into();
299 self
300 }
301
302 pub fn size(mut self, width: impl Into<Length>, height: impl Into<Length>) -> Self {
303 self.width = width.into();
304 self.height = height.into();
305 self
306 }
307
308 pub fn padding(mut self, padding: impl Into<Edges>) -> Self {
309 self.padding = padding.into();
310 self
311 }
312
313 pub fn pad(self, padding: impl Into<Edges>) -> Self {
314 self.padding(padding)
315 }
316
317 pub fn padding_xy(mut self, x: f32, y: f32) -> Self {
318 self.padding = Edges::xy(x, y);
319 self
320 }
321
322 pub fn style(mut self, style: ModalStyle) -> Self {
323 self.style = Some(style);
324 self
325 }
326
327 pub fn background(mut self, color: Color) -> Self {
328 self.background = Some(color);
329 self
330 }
331
332 pub fn border_color(mut self, color: Color) -> Self {
333 self.border = Some(color);
334 self
335 }
336
337 pub fn border_width(mut self, width: f32) -> Self {
338 self.border_width = Some(width.max(0.0));
339 self
340 }
341
342 pub fn border(mut self, width: f32, color: Color) -> Self {
343 self.border_width = Some(width.max(0.0));
344 self.border = Some(color);
345 self
346 }
347
348 pub fn radius(mut self, radius: f32) -> Self {
349 self.radius = Some(radius.max(0.0));
350 self
351 }
352
353 pub fn backdrop(mut self, backdrop: Backdrop) -> Self {
354 self.backdrop = Some(backdrop);
355 self
356 }
357
358 pub fn scrim_color(mut self, color: Color) -> Self {
359 self.scrim = Some(color);
360 self
361 }
362
363 pub fn scrim_backdrop(mut self, backdrop: Backdrop) -> Self {
364 self.scrim_backdrop = Some(backdrop);
365 self
366 }
367
368 pub fn dismiss(mut self, dismiss: Dismiss) -> Self {
369 self.dismiss = dismiss;
370 self
371 }
372
373 pub fn margin(mut self, margin: impl Into<Edges>) -> Self {
374 let m = margin.into();
375 self.margin = Edges::only(
376 sanitize_margin(m.left),
377 sanitize_margin(m.top),
378 sanitize_margin(m.right),
379 sanitize_margin(m.bottom),
380 );
381 self
382 }
383
384 pub fn margin_xy(self, x: f32, y: f32) -> Self {
385 self.margin(Edges::xy(x, y))
386 }
387
388 pub fn margin_x(mut self, x: f32) -> Self {
389 let x = sanitize_margin(x);
390 self.margin.left = x;
391 self.margin.right = x;
392 self
393 }
394
395 pub fn margin_y(mut self, y: f32) -> Self {
396 let y = sanitize_margin(y);
397 self.margin.top = y;
398 self.margin.bottom = y;
399 self
400 }
401
402 pub fn margin_left(mut self, v: f32) -> Self {
403 self.margin.left = sanitize_margin(v);
404 self
405 }
406
407 pub fn margin_right(mut self, v: f32) -> Self {
408 self.margin.right = sanitize_margin(v);
409 self
410 }
411
412 pub fn margin_top(mut self, v: f32) -> Self {
413 self.margin.top = sanitize_margin(v);
414 self
415 }
416
417 pub fn margin_bottom(mut self, v: f32) -> Self {
418 self.margin.bottom = sanitize_margin(v);
419 self
420 }
421
422 pub fn scrim(&self) -> ModalScrim {
423 let style = self.style.unwrap_or_default();
424 ModalScrim {
425 open: self.open.clone(),
426 scrim: self.scrim.or(style.scrim).unwrap_or(DEFAULT_SCRIM),
427 backdrop: self
428 .scrim_backdrop
429 .or(style.backdrop)
430 .unwrap_or(Backdrop::NONE),
431 }
432 }
433
434 pub fn is_open(&self) -> bool {
435 self.open.get_untracked()
436 }
437
438 pub fn open(&self) {
439 self.open.set(true);
440 }
441
442 pub fn close(&self) {
443 self.open.set(false);
444 }
445
446 pub fn toggle(&self) {
447 self.open.set(!self.open.get_untracked());
448 }
449
450 fn key(&self) -> usize {
451 self.open.id()
452 }
453
454 fn sync_overlay(&self, ctx: &mut PaintCtx, theme: &Theme) {
455 let key = self.key();
456 if !self.open.get_untracked() {
457 if let Some(id) = take_entry(key) {
458 ctx.hide_overlay(id);
459 }
460 return;
461 }
462 if let Some(id) = get_entry(key) {
463 if !ctx.has_overlay(id) {
464 take_entry(key);
465 self.open.set(false);
466 }
467 return;
468 }
469 let Some(child) = self.child.clone() else {
470 self.open.set(false);
471 return;
472 };
473 let style = self.style.unwrap_or_default();
474 let radius = self.radius.or(style.radius).unwrap_or(theme.radius);
475 let backdrop = self.backdrop.or(style.backdrop).unwrap_or(Backdrop::MD);
476 let panel = OverlayPanel {
477 child,
478 width: self.width,
479 height: self.height,
480 padding: self.padding,
481 background: self
482 .background
483 .or(style.background)
484 .unwrap_or(theme.surface),
485 border: self.border.or(style.border).unwrap_or(theme.border),
486 border_width: self
487 .border_width
488 .or(style.border_width)
489 .unwrap_or(1.0)
490 .max(0.0),
491 radius,
492 backdrop,
493 };
494
495 let enter = Presence::new(Signal::new(true))
496 .from(0.0)
497 .enter_duration(MODAL_ENTER_SECS)
498 .enter_easing(MODAL_ENTER_EASE)
499 .slide(0.0, MODAL_ENTER_DY)
500 .child(panel);
501 let id = ctx.show_overlay(
502 OverlayEntry::new(enter)
503 .anchor(Anchor::Viewport)
504 .placement(Placement::Center)
505 .layer(OverlayLayer::Modal)
506 .input_mode(InputMode::Modal)
507 .dismiss(self.dismiss)
508 .overflow(OverlayOverflow::Clamp),
509 );
510 insert_entry(key, id);
511 }
512}
513
514impl View for Modal {
515 fn body(&self) -> impl View + 'static {
516 Unit
517 }
518 fn is_leaf(&self) -> bool {
519 true
520 }
521
522 fn hit_testable(&self) -> bool {
523 false
524 }
525 fn keyed_children(&self) -> Option<Vec<(u64, AnyView)>> {
526 let _ = self.open.get();
527 Some(Vec::new())
528 }
529 fn layout_style(&self) -> LayoutStyle {
530 LayoutStyle {
531 width: Length::Fit,
532 height: Length::Fit,
533 margin: self.margin,
534 ..LayoutStyle::default()
535 }
536 }
537 fn on_unmount(&self) {
538 take_entry(self.key());
539 }
540 fn paint_self(&self, ctx: &mut PaintCtx) {
541 let theme = ctx.theme();
542 self.sync_overlay(ctx, &theme);
543 }
544}
545
546fn sanitize_margin(v: f32) -> f32 {
547 if v.is_finite() { v.max(0.0) } else { 0.0 }
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553
554 #[test]
555 fn scrim_clock_is_stable_across_rebuilds() {
556 let open = Signal::new(true);
557 let a = scrim_clock(open.id());
558 let b = scrim_clock(open.id());
559 assert!(Rc::ptr_eq(&a, &b), "same signal = same clock");
560
561 let (v, _) = fade_in_opacity(&a, SCRIM_FADE_SECS);
562 let (v2, _) = fade_in_opacity(&b, SCRIM_FADE_SECS);
563 assert!(v2 >= v, "fade does not undo between rebuilds: {v} -> {v2}");
564 forget_scrim_clock(open.id());
565 }
566
567 #[test]
568 fn scrim_clock_resets_after_close() {
569 let open = Signal::new(true);
570 let a = scrim_clock(open.id());
571 let _ = fade_in_opacity(&a, SCRIM_FADE_SECS);
572 forget_scrim_clock(open.id());
573 let b = scrim_clock(open.id());
574 assert!(!Rc::ptr_eq(&a, &b), "after closing, the clock is fresh");
575 assert!(b.borrow().is_none(), "fresh clock is not anchored");
576 forget_scrim_clock(open.id());
577 }
578}