Skip to main content

telar_ui_core/
surface.rs

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, PointerButton};
10use reactive_core::RwSignal;
11use renderer_core::{Color, RectStyle, TextStyle};
12use ui_tree::{Component, EventResult, RenderNode};
13
14use crate::context::{compute_layout, mark_dirty, new_container, track_layout};
15use crate::layout_item::{LayoutItem, box_item};
16use crate::styled_container::StyledContainer;
17use crate::text::Text;
18
19/// What kind of secondary surface a placement describes. A backend maps the role to its own surface
20/// primitives (a layer-shell backend picks a layer + namespace; a windowed backend a child window or an
21/// in-window portal). Roles carry no behaviour of their own — the explicit [`SurfacePlacement`] fields do.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum SurfaceRole {
24    /// A panel that slides off a bar/edge, dimming what's behind it.
25    Drawer,
26    /// A transient, positioned popup (a notification, a menu detached from its trigger).
27    Popup,
28    /// A brief, non-interactive status flash (volume/brightness), auto-dismissed.
29    Osd,
30    /// A free-floating window with its own title/close affordances.
31    Float,
32    /// A modal that owns the screen while it is up: a launcher, a command palette, a session menu. Unlike a
33    /// [`Drawer`](Self::Drawer) it isn't anchored to an edge, and unlike a [`Float`](Self::Float) it expects to
34    /// take the keyboard outright — the user is typing into it, not at whatever is behind it.
35    Overlay,
36}
37
38/// How much of the keyboard a surface needs.
39///
40/// The distinction matters because it decides who receives a keystroke *before* any click. A panel with a text
41/// field can wait to be clicked into ([`OnDemand`](Self::OnDemand)); a launcher cannot — it opens on a keybind
42/// and the next keystroke is already its first search character, so it has to hold the keyboard from the moment
43/// it maps ([`Exclusive`](Self::Exclusive)). Asking for more than is needed is not free: a surface holding the
44/// keyboard takes it from the focused window.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub enum KeyboardMode {
47    /// Display-only; never takes keyboard focus.
48    #[default]
49    None,
50    /// May be given focus on interaction, e.g. a click into a text field.
51    OnDemand,
52    /// Holds the keyboard for as long as it is mapped.
53    Exclusive,
54}
55
56/// The screen edge (or centre) a surface hugs. The cross axis is aligned by [`SurfaceAlign`].
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum SurfaceAnchor {
59    Top,
60    Bottom,
61    Left,
62    Right,
63    Center,
64}
65
66/// Cross-axis alignment along the anchored edge (e.g. left/centre/right for a top-anchored surface).
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum SurfaceAlign {
69    Start,
70    Center,
71    End,
72}
73
74/// A surface's size: a fixed logical pixel box, or derived from its content.
75#[derive(Debug, Clone, Copy, PartialEq)]
76pub enum SurfaceSize {
77    Fixed(u32, u32),
78    Auto,
79}
80
81/// A backend-agnostic description of a secondary surface: where it sits, how big it is, and how it
82/// behaves (scrim, outside-dismiss, auto-timeout). The intent lives here; a backend derives its own
83/// surface config from it. Reusable by a windowed app (as an in-window portal) and by a shell (as a real
84/// layer-shell surface) alike.
85#[derive(Debug, Clone)]
86pub struct SurfacePlacement {
87    pub role: SurfaceRole,
88    pub anchor: SurfaceAnchor,
89    pub align: SurfaceAlign,
90    pub size: SurfaceSize,
91    /// Gap from the screen edges, as `(top, right, bottom, left)`. A full-screen scrim scaffold applies the
92    /// whole tuple as padding (so the panel floats off every edge, not just the anchored one); a
93    /// directly-anchored surface applies it as the compositor margin.
94    pub margin: (i32, i32, i32, i32),
95    /// Dim (and, with `dismiss_on_outside`, capture) the area behind the panel.
96    pub scrim: bool,
97    /// A press outside the panel dismisses the surface.
98    pub dismiss_on_outside: bool,
99    /// Auto-dismiss after this long; `None` keeps it until closed explicitly.
100    pub timeout: Option<Duration>,
101    /// The surface passes pointer input through to whatever is beneath it (a click-through OSD).
102    pub input_transparent: bool,
103    /// How much of the keyboard the surface needs; a backend maps this to its own focus model (e.g. layer-shell
104    /// keyboard interactivity). Defaults to [`KeyboardMode::None`], so a panel is display-only and never steals
105    /// the keyboard.
106    pub keyboard: KeyboardMode,
107    /// The monitor to place the surface on by name; `None` = the active/default output.
108    pub output: Option<String>,
109}
110
111impl SurfacePlacement {
112    pub fn new(role: SurfaceRole, anchor: SurfaceAnchor) -> Self {
113        Self {
114            role,
115            anchor,
116            align: SurfaceAlign::Center,
117            size: SurfaceSize::Auto,
118            margin: (0, 0, 0, 0),
119            scrim: false,
120            dismiss_on_outside: false,
121            timeout: None,
122            input_transparent: false,
123            keyboard: KeyboardMode::None,
124            output: None,
125        }
126    }
127
128    /// A modal that owns the screen: centred, scrimmed, dismissed by a press outside, and holding the keyboard
129    /// from the moment it maps so the first keystroke after the keybind is already typed into it.
130    pub fn overlay() -> Self {
131        Self {
132            scrim: true,
133            dismiss_on_outside: true,
134            keyboard: KeyboardMode::Exclusive,
135            ..Self::new(SurfaceRole::Overlay, SurfaceAnchor::Center)
136        }
137    }
138
139    pub fn drawer(anchor: SurfaceAnchor) -> Self {
140        Self {
141            scrim: true,
142            dismiss_on_outside: true,
143            ..Self::new(SurfaceRole::Drawer, anchor)
144        }
145    }
146
147    pub fn osd() -> Self {
148        Self {
149            input_transparent: true,
150            ..Self::new(SurfaceRole::Osd, SurfaceAnchor::Top)
151        }
152    }
153
154    pub fn float() -> Self {
155        Self::new(SurfaceRole::Float, SurfaceAnchor::Center)
156    }
157
158    pub fn align(mut self, align: SurfaceAlign) -> Self {
159        self.align = align;
160        self
161    }
162
163    pub fn size(mut self, size: SurfaceSize) -> Self {
164        self.size = size;
165        self
166    }
167
168    pub fn margin(mut self, margin: (i32, i32, i32, i32)) -> Self {
169        self.margin = margin;
170        self
171    }
172
173    pub fn inset(mut self, px: i32) -> Self {
174        let (t, r, b, l) = self.margin;
175        self.margin = match self.anchor {
176            SurfaceAnchor::Top => (px, r, b, l),
177            SurfaceAnchor::Bottom => (t, r, px, l),
178            SurfaceAnchor::Left => (t, r, b, px),
179            SurfaceAnchor::Right => (t, px, b, l),
180            SurfaceAnchor::Center => (t, r, b, l),
181        };
182        self
183    }
184
185    pub fn scrim(mut self, scrim: bool) -> Self {
186        self.scrim = scrim;
187        self
188    }
189
190    pub fn dismiss_on_outside(mut self, dismiss: bool) -> Self {
191        self.dismiss_on_outside = dismiss;
192        self
193    }
194
195    pub fn timeout(mut self, timeout: Duration) -> Self {
196        self.timeout = Some(timeout);
197        self
198    }
199
200    pub fn input_transparent(mut self, transparent: bool) -> Self {
201        self.input_transparent = transparent;
202        self
203    }
204
205    /// Opt the surface into focus-on-interaction, for panels that host editable text (a search box, a note
206    /// title). Sugar for [`keyboard_mode`](Self::keyboard_mode) with
207    /// [`OnDemand`](KeyboardMode::OnDemand)/[`None`](KeyboardMode::None).
208    pub fn keyboard(mut self, wants_keyboard: bool) -> Self {
209        self.keyboard = if wants_keyboard {
210            KeyboardMode::OnDemand
211        } else {
212            KeyboardMode::None
213        };
214        self
215    }
216
217    /// Sets exactly how much of the keyboard the surface takes.
218    pub fn keyboard_mode(mut self, mode: KeyboardMode) -> Self {
219        self.keyboard = mode;
220        self
221    }
222
223    /// Whether the surface takes keyboard focus at all.
224    pub fn wants_keyboard(&self) -> bool {
225        self.keyboard != KeyboardMode::None
226    }
227
228    pub fn output(mut self, output: Option<String>) -> Self {
229        self.output = output;
230        self
231    }
232
233    /// Whether the surface needs a full-viewport scaffold (to draw a scrim or catch outside presses)
234    /// rather than being anchored directly at its content size.
235    pub fn needs_scaffold(&self) -> bool {
236        self.scrim || self.dismiss_on_outside
237    }
238}
239
240/// The default scrim wash: ~35 % black over the content behind a drawer/modal. Rendered as a fill (not an
241/// opacity layer) so the panel above it stays fully opaque.
242pub const DEFAULT_SCRIM: Color = Color::rgba(0.0, 0.0, 0.0, 0.35);
243
244fn cross_align(align: SurfaceAlign) -> AlignItems {
245    match align {
246        SurfaceAlign::Start => AlignItems::START,
247        SurfaceAlign::Center => AlignItems::CENTER,
248        SurfaceAlign::End => AlignItems::END,
249    }
250}
251
252/// Enter-animation duration and slide travel.
253const ENTER_MS: u64 = 200;
254const SLIDE_DISTANCE: f32 = 24.0;
255const IDENTITY: [f32; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
256
257#[derive(Clone, Copy)]
258enum EnterMotion {
259    Slide(SurfaceAnchor),
260    Fade,
261}
262
263/// The transform matrix and opacity for an enter animation at `progress` (0 = just opened, 1 = settled).
264/// A slide starts `SLIDE_DISTANCE` off its edge and eases to rest; both forms fade in.
265fn enter_transform(motion: EnterMotion, progress: f32) -> ([f32; 6], f32) {
266    let p = progress.clamp(0.0, 1.0);
267    let opacity = p;
268    match motion {
269        EnterMotion::Fade => (IDENTITY, opacity),
270        EnterMotion::Slide(anchor) => {
271            let d = SLIDE_DISTANCE * (1.0 - p);
272            let (dx, dy) = match anchor {
273                SurfaceAnchor::Top => (0.0, -d),
274                SurfaceAnchor::Bottom => (0.0, d),
275                SurfaceAnchor::Left => (-d, 0.0),
276                SurfaceAnchor::Right => (d, 0.0),
277                SurfaceAnchor::Center => (0.0, 0.0),
278            };
279            (Transform::translate(dx, dy).to_array(), opacity)
280        }
281    }
282}
283
284fn apply_enter(node: RenderNode, matrix: [f32; 6], opacity: f32) -> RenderNode {
285    let faded = if opacity < 1.0 {
286        RenderNode::layer(opacity, 0.0, [node])
287    } else {
288        node
289    };
290    if matrix == IDENTITY {
291        faded
292    } else {
293        RenderNode::transform_with(matrix, [faded])
294    }
295}
296
297fn enter_animation() -> Animated<f32> {
298    let anim = Animated::new(0.0, tween(Duration::from_millis(ENTER_MS), Easing::EaseOut));
299    anim.retarget(1.0);
300    anim
301}
302
303/// A full-viewport scaffold that positions a panel against a screen edge, optionally dims the area behind
304/// it, and dismisses on a press outside the panel. It is the reusable body of a drawer/modal: a shell
305/// mounts it as the root of a full-screen layer-shell surface, and a windowed app can mount it in-tree as
306/// an in-window portal — both get the same positioning and dismiss behaviour.
307///
308/// Like other full-surface roots it (re)computes its own layout on `WindowResized`; the runner synthesizes
309/// an initial one on resume, so the scaffold is laid out before its first frame.
310pub struct SurfaceScaffold {
311    root: NodeId,
312    panel_rect: Option<RwSignal<Rect>>,
313    root_rect: Option<RwSignal<Rect>>,
314    content: Box<dyn LayoutItem>,
315    scrim: Option<Color>,
316    dismiss: Option<Rc<dyn Fn()>>,
317    anchor: SurfaceAnchor,
318    enter: Option<Animated<f32>>,
319}
320
321impl SurfaceScaffold {
322    pub fn new(
323        placement: &SurfacePlacement,
324        content: Box<dyn LayoutItem>,
325        dismiss: Option<Rc<dyn Fn()>>,
326    ) -> Result<Self, LayoutError> {
327        let panel_node = content.layout_node();
328        let (mt, mr, mb, ml) = placement.margin;
329        let cross = cross_align(placement.align);
330        // The full margin becomes padding so the panel floats off every screen edge, not just the anchored one;
331        // the per-anchor direction/justify then pins it to its edge within that padded box.
332        let base = LayoutStyle::new()
333            .width(SizeDimension::Percent(1.0))
334            .height(SizeDimension::Percent(1.0))
335            .padding_top(mt as f32)
336            .padding_right(mr as f32)
337            .padding_bottom(mb as f32)
338            .padding_left(ml as f32);
339        let style = match placement.anchor {
340            SurfaceAnchor::Top => base
341                .flex_column()
342                .justify_content(JustifyContent::START)
343                .align_items(cross),
344            SurfaceAnchor::Bottom => base
345                .flex_column()
346                .justify_content(JustifyContent::END)
347                .align_items(cross),
348            SurfaceAnchor::Left => base
349                .flex_row()
350                .justify_content(JustifyContent::START)
351                .align_items(cross),
352            SurfaceAnchor::Right => base
353                .flex_row()
354                .justify_content(JustifyContent::END)
355                .align_items(cross),
356            SurfaceAnchor::Center => base
357                .flex_column()
358                .justify_content(JustifyContent::CENTER)
359                .align_items(AlignItems::CENTER),
360        };
361        let root = new_container(style, &[panel_node])?;
362        let panel_rect = track_layout(panel_node);
363        let root_rect = track_layout(root);
364        let dismiss = if placement.dismiss_on_outside {
365            dismiss
366        } else {
367            None
368        };
369        Ok(Self {
370            root,
371            panel_rect,
372            root_rect,
373            content,
374            scrim: placement.scrim.then_some(DEFAULT_SCRIM),
375            dismiss,
376            anchor: placement.anchor,
377            enter: None,
378        })
379    }
380
381    pub fn animate_in(mut self) -> Self {
382        self.enter = Some(enter_animation());
383        self
384    }
385}
386
387impl LayoutItem for SurfaceScaffold {
388    fn layout_node(&self) -> NodeId {
389        self.root
390    }
391}
392
393impl Component for SurfaceScaffold {
394    fn view(&self) -> RenderNode {
395        let content = self.content.view();
396        let (matrix, opacity) = match &self.enter {
397            Some(anim) => enter_transform(EnterMotion::Slide(self.anchor), anim.get()),
398            None => (IDENTITY, 1.0),
399        };
400        let panel = apply_enter(content, matrix, opacity);
401        match (self.scrim, self.root_rect.as_ref()) {
402            (Some(color), Some(rect)) => {
403                let scrim = apply_enter(
404                    RenderNode::rect(rect.get(), RectStyle::filled(color, 0.0)),
405                    IDENTITY,
406                    opacity,
407                );
408                RenderNode::group([scrim, panel])
409            }
410            _ => panel,
411        }
412    }
413
414    fn on_event(&mut self, event: &Event) -> EventResult {
415        match event {
416            Event::WindowResized { width, height } => {
417                mark_dirty(self.root).ok();
418                compute_layout(
419                    self.root,
420                    AvailableSpace::Definite(*width as f32),
421                    AvailableSpace::Definite(*height as f32),
422                )
423                .ok();
424                EventResult::Handled
425            }
426            Event::PointerPressed {
427                x,
428                y,
429                button: PointerButton::Primary,
430                ..
431            } => {
432                let inside = self
433                    .panel_rect
434                    .as_ref()
435                    .map(|r| r.get().contains(*x as f32, *y as f32))
436                    .unwrap_or(false);
437                match (inside, &self.dismiss) {
438                    (false, Some(dismiss)) => {
439                        dismiss();
440                        EventResult::Handled
441                    }
442                    _ => self.content.on_event(event),
443                }
444            }
445            _ => self.content.on_event(event),
446        }
447    }
448
449    fn debug_name(&self) -> &'static str {
450        "SurfaceScaffold"
451    }
452}
453
454pub struct SurfaceRoot {
455    root: NodeId,
456    content: Box<dyn LayoutItem>,
457    enter: Option<Animated<f32>>,
458}
459
460impl SurfaceRoot {
461    pub fn new(content: Box<dyn LayoutItem>) -> Result<Self, LayoutError> {
462        let root = new_container(
463            LayoutStyle::new()
464                .flex_row()
465                .width(SizeDimension::Percent(1.0))
466                .height(SizeDimension::Percent(1.0)),
467            &[content.layout_node()],
468        )?;
469        Ok(Self {
470            root,
471            content,
472            enter: None,
473        })
474    }
475
476    pub fn animate_in(mut self) -> Self {
477        self.enter = Some(enter_animation());
478        self
479    }
480}
481
482impl LayoutItem for SurfaceRoot {
483    fn layout_node(&self) -> NodeId {
484        self.root
485    }
486}
487
488impl Component for SurfaceRoot {
489    fn view(&self) -> RenderNode {
490        let content = self.content.view();
491        match &self.enter {
492            Some(anim) => {
493                let (_, opacity) = enter_transform(EnterMotion::Fade, anim.get());
494                apply_enter(content, IDENTITY, opacity)
495            }
496            None => content,
497        }
498    }
499
500    fn on_event(&mut self, event: &Event) -> EventResult {
501        if let Event::WindowResized { width, height } = event {
502            mark_dirty(self.root).ok();
503            compute_layout(
504                self.root,
505                AvailableSpace::Definite(*width as f32),
506                AvailableSpace::Definite(*height as f32),
507            )
508            .ok();
509            return EventResult::Handled;
510        }
511        self.content.on_event(event)
512    }
513
514    fn debug_name(&self) -> &'static str {
515        "SurfaceRoot"
516    }
517}
518
519#[derive(Debug, Clone, Copy)]
520pub struct SurfaceFrameStyle {
521    pub background: Color,
522    pub title_bar: Color,
523    pub title_text: Color,
524    pub close: Color,
525    pub radius: f32,
526    pub font_size: f32,
527}
528
529pub fn surface_frame(
530    title: impl Into<String>,
531    style: SurfaceFrameStyle,
532    close: std::rc::Rc<dyn Fn()>,
533    body: Box<dyn LayoutItem>,
534) -> Result<Box<dyn LayoutItem>, LayoutError> {
535    let title = title.into();
536    let title_color = style.title_text;
537    let font_size = style.font_size;
538    let title_label = box_item(Text::auto(
539        move || title.clone(),
540        LayoutStyle::new(),
541        move || TextStyle::new(font_size, title_color),
542    )?);
543
544    let close_color = style.close;
545    let close_label = box_item(Text::auto(
546        || "\u{2715}".to_string(),
547        LayoutStyle::new(),
548        move || TextStyle::new(font_size, close_color),
549    )?);
550    let close_button = box_item(
551        StyledContainer::new(
552            LayoutStyle::new()
553                .align_items(AlignItems::CENTER)
554                .justify_content(JustifyContent::CENTER)
555                .padding_horizontal(8.0)
556                .padding_vertical(2.0),
557            |_| RectStyle::default(),
558            vec![close_label],
559        )?
560        .on_press(move || close()),
561    );
562
563    let title_bar_color = style.title_bar;
564    let title_bar = box_item(StyledContainer::new(
565        LayoutStyle::new()
566            .flex_row()
567            .align_items(AlignItems::CENTER)
568            .justify_content(JustifyContent::SPACE_BETWEEN)
569            .width(SizeDimension::Percent(1.0))
570            .padding_horizontal(12.0)
571            .padding_vertical(8.0),
572        move |_| RectStyle::filled(title_bar_color, 0.0),
573        vec![title_label, close_button],
574    )?);
575
576    let body_area = box_item(StyledContainer::new(
577        LayoutStyle::new()
578            .flex_column()
579            .flex_grow(1.0)
580            .width(SizeDimension::Percent(1.0))
581            .align_items(AlignItems::CENTER)
582            .justify_content(JustifyContent::CENTER)
583            .padding_all(12.0),
584        |_| RectStyle::default(),
585        vec![body],
586    )?);
587
588    let background = style.background;
589    let radius = style.radius;
590    let card = StyledContainer::new(
591        LayoutStyle::new()
592            .flex_column()
593            .width(SizeDimension::Percent(1.0))
594            .height(SizeDimension::Percent(1.0)),
595        move |_| RectStyle::filled(background, radius),
596        vec![title_bar, body_area],
597    )?;
598    Ok(box_item(card))
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604    use std::cell::Cell;
605
606    use platform_core::PointerSource;
607    use renderer_core::RectStyle;
608
609    use crate::StyledContainer;
610    use crate::context::reset_layout_runtime;
611    use crate::layout_item::box_item;
612
613    fn panel() -> Box<dyn LayoutItem> {
614        box_item(
615            StyledContainer::new(
616                LayoutStyle::new().width(100.0).height(40.0),
617                |_r| RectStyle::default(),
618                vec![],
619            )
620            .unwrap(),
621        )
622    }
623
624    fn press(x: f64, y: f64) -> Event {
625        Event::PointerPressed {
626            x,
627            y,
628            button: PointerButton::Primary,
629            source: PointerSource::Mouse,
630        }
631    }
632
633    #[test]
634    fn dismiss_fires_on_outside_press_only() {
635        reset_layout_runtime();
636        let fired = Rc::new(Cell::new(0u32));
637        let f = fired.clone();
638        let placement = SurfacePlacement::drawer(SurfaceAnchor::Top).inset(20);
639        let mut scaffold = SurfaceScaffold::new(
640            &placement,
641            panel(),
642            Some(Rc::new(move || f.set(f.get() + 1))),
643        )
644        .unwrap();
645        scaffold.on_event(&Event::WindowResized {
646            width: 200,
647            height: 200,
648        });
649
650        scaffold.on_event(&press(100.0, 40.0));
651        assert_eq!(fired.get(), 0, "a press inside the panel must not dismiss");
652        scaffold.on_event(&press(10.0, 190.0));
653        assert_eq!(fired.get(), 1, "a press outside the panel dismisses");
654    }
655
656    #[test]
657    fn cross_axis_margin_insets_the_panel_from_the_side_edges() {
658        reset_layout_runtime();
659        let fired = Rc::new(Cell::new(0u32));
660        let f = fired.clone();
661        // Start-aligned top drawer, floated 10px off every edge: the 100-wide panel sits at x in [10, 110].
662        let placement = SurfacePlacement::drawer(SurfaceAnchor::Top)
663            .align(SurfaceAlign::Start)
664            .margin((30, 10, 10, 10));
665        let mut scaffold = SurfaceScaffold::new(
666            &placement,
667            panel(),
668            Some(Rc::new(move || f.set(f.get() + 1))),
669        )
670        .unwrap();
671        scaffold.on_event(&Event::WindowResized {
672            width: 200,
673            height: 200,
674        });
675
676        scaffold.on_event(&press(50.0, 40.0));
677        assert_eq!(
678            fired.get(),
679            0,
680            "a press on the inset panel must not dismiss"
681        );
682        scaffold.on_event(&press(4.0, 40.0));
683        assert_eq!(
684            fired.get(),
685            1,
686            "a press in the left gap (before the inset) dismisses"
687        );
688        scaffold.on_event(&press(150.0, 40.0));
689        assert_eq!(fired.get(), 2, "a press in the right gap dismisses");
690    }
691
692    #[test]
693    fn no_dismiss_when_not_configured() {
694        reset_layout_runtime();
695        let fired = Rc::new(Cell::new(0u32));
696        let f = fired.clone();
697        let placement = SurfacePlacement::new(SurfaceRole::Drawer, SurfaceAnchor::Top).scrim(true);
698        let mut scaffold = SurfaceScaffold::new(
699            &placement,
700            panel(),
701            Some(Rc::new(move || f.set(f.get() + 1))),
702        )
703        .unwrap();
704        scaffold.on_event(&Event::WindowResized {
705            width: 200,
706            height: 200,
707        });
708
709        scaffold.on_event(&press(10.0, 190.0));
710        assert_eq!(
711            fired.get(),
712            0,
713            "no dismiss must fire when dismiss_on_outside is off"
714        );
715    }
716
717    #[test]
718    fn enter_transform_fade_is_opacity_only() {
719        assert_eq!(enter_transform(EnterMotion::Fade, 0.0), (IDENTITY, 0.0));
720        assert_eq!(enter_transform(EnterMotion::Fade, 0.5), (IDENTITY, 0.5));
721        assert_eq!(enter_transform(EnterMotion::Fade, 1.0), (IDENTITY, 1.0));
722    }
723
724    #[test]
725    fn enter_transform_slide_offsets_from_edge_then_settles() {
726        let (m, o) = enter_transform(EnterMotion::Slide(SurfaceAnchor::Top), 0.0);
727        assert_eq!(o, 0.0);
728        assert_eq!(m[5], -SLIDE_DISTANCE, "top slides down from above");
729        assert_eq!(
730            enter_transform(EnterMotion::Slide(SurfaceAnchor::Top), 1.0),
731            (IDENTITY, 1.0)
732        );
733        assert_eq!(
734            enter_transform(EnterMotion::Slide(SurfaceAnchor::Bottom), 0.0).0[5],
735            SLIDE_DISTANCE
736        );
737        assert_eq!(
738            enter_transform(EnterMotion::Slide(SurfaceAnchor::Left), 0.0).0[4],
739            -SLIDE_DISTANCE
740        );
741        assert_eq!(
742            enter_transform(EnterMotion::Slide(SurfaceAnchor::Right), 0.0).0[4],
743            SLIDE_DISTANCE
744        );
745    }
746}