1use std::rc::Rc;
2use std::time::Duration;
3
4use geometry_core::{Rect, Transform};
5use layout_core::{
6 AlignItems, AvailableSpace, JustifyContent, LayoutError, LayoutStyle, NodeId, SizeDimension,
7};
8use motion_core::{Animated, Easing, tween};
9use platform_core::{Event, Key, NamedKey, PointerButton};
10use reactive_core::RwSignal;
11use renderer_core::{Color, RectStyle};
12use ui_tree::{Component, EventResult, RenderNode};
13
14use crate::context::{compute_layout, mark_dirty, new_container, track_layout};
15use crate::layout_item::LayoutItem;
16
17pub const DEFAULT_SCRIM: Color = Color::rgba(0.0, 0.0, 0.0, 0.35);
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Edge {
28 Top,
29 Bottom,
30 Left,
31 Right,
32 Center,
33}
34
35const ENTER_MS: u64 = 200;
37const SLIDE_DISTANCE: f32 = 24.0;
38pub(crate) const IDENTITY: [f32; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
39
40#[derive(Clone, Copy)]
41pub(crate) enum EnterMotion {
42 Slide(Edge),
43 Fade,
44}
45
46pub(crate) fn enter_transform(motion: EnterMotion, progress: f32) -> ([f32; 6], f32) {
49 let p = progress.clamp(0.0, 1.0);
50 let opacity = p;
51 match motion {
52 EnterMotion::Fade => (IDENTITY, opacity),
53 EnterMotion::Slide(anchor) => {
54 let d = SLIDE_DISTANCE * (1.0 - p);
55 let (dx, dy) = match anchor {
56 Edge::Top => (0.0, -d),
57 Edge::Bottom => (0.0, d),
58 Edge::Left => (-d, 0.0),
59 Edge::Right => (d, 0.0),
60 Edge::Center => (0.0, 0.0),
61 };
62 (Transform::translate(dx, dy).to_array(), opacity)
63 }
64 }
65}
66
67pub(crate) fn apply_enter(node: RenderNode, matrix: [f32; 6], opacity: f32) -> RenderNode {
68 let faded = if opacity < 1.0 {
69 RenderNode::layer(opacity, 0.0, [node])
70 } else {
71 node
72 };
73 if matrix == IDENTITY {
74 faded
75 } else {
76 RenderNode::transform_with(matrix, [faded])
77 }
78}
79
80#[derive(Clone)]
87pub struct SurfaceTransition {
88 progress: Animated<f32>,
89 duration: Duration,
90}
91
92impl SurfaceTransition {
93 pub fn enter() -> Self {
97 let duration = Duration::from_millis(ENTER_MS);
98 let progress = Animated::new(0.0, tween(duration, Easing::EaseOut));
99 progress.retarget(1.0);
100 Self { progress, duration }
101 }
102
103 pub fn leave(&self) {
106 self.progress.retarget(0.0);
107 }
108
109 pub fn duration(&self) -> Duration {
111 self.duration
112 }
113
114 pub(crate) fn get(&self) -> f32 {
115 self.progress.get()
116 }
117}
118
119pub struct SurfaceScaffold {
127 root: NodeId,
128 panel_rect: Option<RwSignal<Rect>>,
129 root_rect: Option<RwSignal<Rect>>,
130 content: Box<dyn LayoutItem>,
131 scrim: Option<Color>,
132 dismiss: Option<Rc<dyn Fn()>>,
133 edge: Edge,
134 transition: Option<SurfaceTransition>,
135}
136
137impl SurfaceScaffold {
138 pub fn new(
143 edge: Edge,
144 align: AlignItems,
145 margin: (i32, i32, i32, i32),
146 scrim: Option<Color>,
147 dismiss: Option<Rc<dyn Fn()>>,
148 content: Box<dyn LayoutItem>,
149 ) -> Result<Self, LayoutError> {
150 let panel_node = content.layout_node();
151 let (mt, mr, mb, ml) = margin;
152 let base = LayoutStyle::new()
153 .width(SizeDimension::Percent(1.0))
154 .height(SizeDimension::Percent(1.0))
155 .padding_top(mt as f32)
156 .padding_right(mr as f32)
157 .padding_bottom(mb as f32)
158 .padding_left(ml as f32);
159 let style = match edge {
160 Edge::Top => base
161 .flex_column()
162 .justify_content(JustifyContent::START)
163 .align_items(align),
164 Edge::Bottom => base
165 .flex_column()
166 .justify_content(JustifyContent::END)
167 .align_items(align),
168 Edge::Left => base
169 .flex_row()
170 .justify_content(JustifyContent::START)
171 .align_items(align),
172 Edge::Right => base
173 .flex_row()
174 .justify_content(JustifyContent::END)
175 .align_items(align),
176 Edge::Center => base
177 .flex_column()
178 .justify_content(JustifyContent::CENTER)
179 .align_items(AlignItems::CENTER),
180 };
181 let root = new_container(style, &[panel_node])?;
182 let panel_rect = track_layout(panel_node);
183 let root_rect = track_layout(root);
184 Ok(Self {
185 root,
186 panel_rect,
187 root_rect,
188 content,
189 scrim,
190 dismiss,
191 edge,
192 transition: None,
193 })
194 }
195
196 pub fn animate_in(self) -> Self {
197 self.animate(SurfaceTransition::enter())
198 }
199
200 pub fn animate(mut self, transition: SurfaceTransition) -> Self {
203 self.transition = Some(transition);
204 self
205 }
206}
207
208impl LayoutItem for SurfaceScaffold {
209 fn layout_node(&self) -> NodeId {
210 self.root
211 }
212}
213
214impl Component for SurfaceScaffold {
215 fn view(&self) -> RenderNode {
216 let content = self.content.view();
217 let (matrix, opacity) = match &self.transition {
218 Some(transition) => enter_transform(EnterMotion::Slide(self.edge), transition.get()),
219 None => (IDENTITY, 1.0),
220 };
221 let panel = apply_enter(content, matrix, opacity);
222 match (self.scrim, self.root_rect.as_ref()) {
223 (Some(color), Some(rect)) => {
224 let scrim = apply_enter(
225 RenderNode::rect(rect.get(), RectStyle::filled(color, 0.0)),
226 IDENTITY,
227 opacity,
228 );
229 RenderNode::group([scrim, panel])
230 }
231 _ => panel,
232 }
233 }
234
235 fn on_event(&mut self, event: &Event) -> EventResult {
236 match event {
237 Event::WindowResized { width, height } => {
238 mark_dirty(self.root).ok();
239 compute_layout(
240 self.root,
241 AvailableSpace::Definite(*width as f32),
242 AvailableSpace::Definite(*height as f32),
243 )
244 .ok();
245 EventResult::Handled
246 }
247 Event::PointerPressed {
248 x,
249 y,
250 button: PointerButton::Primary,
251 ..
252 } => {
253 let inside = self
254 .panel_rect
255 .as_ref()
256 .map(|r| r.get().contains(*x as f32, *y as f32))
257 .unwrap_or(false);
258 match (inside, &self.dismiss) {
259 (false, Some(dismiss)) => {
260 dismiss();
261 EventResult::Handled
262 }
263 _ => self.content.on_event(event),
264 }
265 }
266 Event::KeyPressed {
271 key: Key::Named(NamedKey::Escape),
272 ..
273 } => match (self.content.on_event(event), &self.dismiss) {
274 (EventResult::Ignored, Some(dismiss)) => {
275 dismiss();
276 EventResult::Handled
277 }
278 (result, _) => result,
279 },
280 _ => self.content.on_event(event),
281 }
282 }
283
284 fn debug_name(&self) -> &'static str {
285 "SurfaceScaffold"
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use renderer_core::TextStyle;
293 use std::cell::Cell;
294
295 use platform_core::PointerSource;
296 use renderer_core::RectStyle;
297
298 use crate::StyledContainer;
299 use crate::context::reset_layout_runtime;
300 use crate::layout_item::box_item;
301
302 fn panel() -> Box<dyn LayoutItem> {
303 box_item(
304 StyledContainer::new(
305 LayoutStyle::new().width(100.0).height(40.0),
306 |_r| RectStyle::default(),
307 vec![],
308 )
309 .unwrap(),
310 )
311 }
312
313 fn press(x: f64, y: f64) -> Event {
314 Event::PointerPressed {
315 x,
316 y,
317 button: PointerButton::Primary,
318 source: PointerSource::Mouse,
319 }
320 }
321
322 #[test]
323 fn dismiss_fires_on_outside_press_only() {
324 reset_layout_runtime();
325 let fired = Rc::new(Cell::new(0u32));
326 let f = fired.clone();
327 let mut scaffold = SurfaceScaffold::new(
328 Edge::Top,
329 AlignItems::CENTER,
330 (20, 20, 20, 20),
331 Some(DEFAULT_SCRIM),
332 Some(Rc::new(move || f.set(f.get() + 1))),
333 panel(),
334 )
335 .unwrap();
336 scaffold.on_event(&Event::WindowResized {
337 width: 200,
338 height: 200,
339 });
340
341 scaffold.on_event(&press(100.0, 40.0));
342 assert_eq!(fired.get(), 0, "a press inside the panel must not dismiss");
343 scaffold.on_event(&press(10.0, 190.0));
344 assert_eq!(fired.get(), 1, "a press outside the panel dismisses");
345 }
346
347 #[test]
351 fn escape_dismisses_only_what_the_panel_left_alone() {
352 reset_layout_runtime();
353 let fired = Rc::new(Cell::new(0u32));
354 let f = fired.clone();
355 let mut scaffold = SurfaceScaffold::new(
356 Edge::Top,
357 AlignItems::CENTER,
358 (20, 20, 20, 20),
359 Some(DEFAULT_SCRIM),
360 Some(Rc::new(move || f.set(f.get() + 1))),
361 panel(),
362 )
363 .unwrap();
364 scaffold.on_event(&Event::WindowResized {
365 width: 200,
366 height: 200,
367 });
368
369 let escape = Event::KeyPressed {
370 key: Key::Named(NamedKey::Escape),
371 modifiers: Default::default(),
372 };
373 assert_eq!(scaffold.on_event(&escape), EventResult::Handled);
374 assert_eq!(
375 fired.get(),
376 1,
377 "a plain panel lets Escape close the surface"
378 );
379
380 reset_layout_runtime();
381 let untouched = Rc::new(Cell::new(0u32));
382 let u = untouched.clone();
383 let field = crate::input::Input::new(
384 reactive_core::signal(String::new()),
385 LayoutStyle::new().width(100.0).height(30.0),
386 || TextStyle::new(14.0, Color::BLACK),
387 )
388 .unwrap()
389 .autofocus();
390 let mut focused = SurfaceScaffold::new(
391 Edge::Top,
392 AlignItems::CENTER,
393 (20, 20, 20, 20),
394 Some(DEFAULT_SCRIM),
395 Some(Rc::new(move || u.set(u.get() + 1))),
396 box_item(field),
397 )
398 .unwrap();
399 focused.on_event(&Event::WindowResized {
400 width: 200,
401 height: 200,
402 });
403 focused.on_event(&press(100.0, 40.0));
404 focused.on_event(&escape);
405 assert_eq!(
406 untouched.get(),
407 0,
408 "a field that claims Escape to release its own focus keeps the surface up"
409 );
410 }
411
412 #[test]
413 fn cross_axis_margin_insets_the_panel_from_the_side_edges() {
414 reset_layout_runtime();
415 let fired = Rc::new(Cell::new(0u32));
416 let f = fired.clone();
417 let mut scaffold = SurfaceScaffold::new(
419 Edge::Top,
420 AlignItems::START,
421 (30, 10, 10, 10),
422 Some(DEFAULT_SCRIM),
423 Some(Rc::new(move || f.set(f.get() + 1))),
424 panel(),
425 )
426 .unwrap();
427 scaffold.on_event(&Event::WindowResized {
428 width: 200,
429 height: 200,
430 });
431
432 scaffold.on_event(&press(50.0, 40.0));
433 assert_eq!(
434 fired.get(),
435 0,
436 "a press on the inset panel must not dismiss"
437 );
438 scaffold.on_event(&press(4.0, 40.0));
439 assert_eq!(
440 fired.get(),
441 1,
442 "a press in the left gap (before the inset) dismisses"
443 );
444 scaffold.on_event(&press(150.0, 40.0));
445 assert_eq!(fired.get(), 2, "a press in the right gap dismisses");
446 }
447
448 #[test]
449 fn no_dismiss_when_not_configured() {
450 reset_layout_runtime();
451 let fired = Rc::new(Cell::new(0u32));
452 let f = fired.clone();
453 let mut scaffold = SurfaceScaffold::new(
455 Edge::Top,
456 AlignItems::CENTER,
457 (0, 0, 0, 0),
458 Some(DEFAULT_SCRIM),
459 None,
460 panel(),
461 )
462 .unwrap();
463 let _ = &f;
464 scaffold.on_event(&Event::WindowResized {
465 width: 200,
466 height: 200,
467 });
468
469 scaffold.on_event(&press(10.0, 190.0));
470 assert_eq!(
471 fired.get(),
472 0,
473 "no dismiss must fire when dismiss_on_outside is off"
474 );
475 }
476 #[test]
477 fn enter_transform_fade_is_opacity_only() {
478 assert_eq!(enter_transform(EnterMotion::Fade, 0.0), (IDENTITY, 0.0));
479 assert_eq!(enter_transform(EnterMotion::Fade, 0.5), (IDENTITY, 0.5));
480 assert_eq!(enter_transform(EnterMotion::Fade, 1.0), (IDENTITY, 1.0));
481 }
482
483 #[test]
484 fn enter_transform_slide_offsets_from_edge_then_settles() {
485 let (m, o) = enter_transform(EnterMotion::Slide(Edge::Top), 0.0);
486 assert_eq!(o, 0.0);
487 assert_eq!(m[5], -SLIDE_DISTANCE, "top slides down from above");
488 assert_eq!(
489 enter_transform(EnterMotion::Slide(Edge::Top), 1.0),
490 (IDENTITY, 1.0)
491 );
492 assert_eq!(
493 enter_transform(EnterMotion::Slide(Edge::Bottom), 0.0).0[5],
494 SLIDE_DISTANCE
495 );
496 assert_eq!(
497 enter_transform(EnterMotion::Slide(Edge::Left), 0.0).0[4],
498 -SLIDE_DISTANCE
499 );
500 assert_eq!(
501 enter_transform(EnterMotion::Slide(Edge::Right), 0.0).0[4],
502 SLIDE_DISTANCE
503 );
504 }
505}