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