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, 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
17/// The default scrim wash: ~35 % black over the content behind a drawer/modal. Rendered as a fill (not an
18/// opacity layer) so the panel above it stays fully opaque. Kept as the value a caller reaches for rather
19/// than being folded into the scaffold, because [`SurfaceScaffold`] now takes the colour itself.
20pub const DEFAULT_SCRIM: Color = Color::rgba(0.0, 0.0, 0.0, 0.35);
21
22/// Which side of the viewport a [`SurfaceScaffold`] pins its panel to, and the direction it slides in from.
23///
24/// [`Center`](Self::Edge::Center) is not an edge: it means the panel is centred on both axes and arrives by
25/// fading rather than sliding, which is what a launcher or a command palette wants.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Edge {
28    Top,
29    Bottom,
30    Left,
31    Right,
32    Center,
33}
34
35/// Enter-animation duration and slide travel.
36const 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
46/// The transform matrix and opacity for an enter animation at `progress` (0 = just opened, 1 = settled).
47/// A slide starts `SLIDE_DISTANCE` off its edge and eases to rest; both forms fade in.
48pub(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/// The one progress value a surface's arrival and departure share: 0 is off its edge and transparent, 1 is
81/// settled. Opening runs it to 1; [`leave`](Self::leave) runs it back to 0, so the exit is the entrance
82/// reversed rather than a second animation that has to be kept in step with the first.
83///
84/// A backend that can hold a closing surface on screen for [`duration`](Self::duration) is what makes the exit
85/// half visible; one that cannot simply never calls `leave`, and the surface disappears as it always did.
86#[derive(Clone)]
87pub struct SurfaceTransition {
88    progress: Animated<f32>,
89    duration: Duration,
90}
91
92impl SurfaceTransition {
93    /// A transition already on its way in. Constructed away from its goal and retargeted at once, never *at*
94    /// it: an `Animated` born settled registers with no ticker, so nothing would schedule the frames that carry
95    /// it in.
96    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    /// Sends the surface back the way it came. The caller is responsible for keeping it on screen for
104    /// [`duration`](Self::duration) — otherwise this animates a surface that has already been torn down.
105    pub fn leave(&self) {
106        self.progress.retarget(0.0);
107    }
108
109    /// How long either half takes, and therefore how long a closing surface has to stay mapped.
110    pub fn duration(&self) -> Duration {
111        self.duration
112    }
113
114    pub(crate) fn get(&self) -> f32 {
115        self.progress.get()
116    }
117}
118
119/// A full-viewport scaffold that positions a panel against a screen edge, optionally dims the area behind
120/// it, and dismisses on a press outside the panel. It is the reusable body of a drawer/modal: a shell
121/// mounts it as the root of a full-screen layer-shell surface, and a windowed app can mount it in-tree as
122/// an in-window portal — both get the same positioning and dismiss behaviour.
123///
124/// Like other full-surface roots it (re)computes its own layout on `WindowResized`; the runner synthesizes
125/// an initial one on resume, so the scaffold is laid out before its first frame.
126pub 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    /// `margin` is `(top, right, bottom, left)` and becomes padding on all four sides, so the panel floats off
139    /// every viewport edge rather than only the one it is pinned to. `scrim` paints behind the panel when set
140    /// (see [`DEFAULT_SCRIM`]); `dismiss` fires on a press outside it, and `None` means outside presses fall
141    /// through.
142    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    /// Drives the scaffold from a transition the *caller* owns, so it can also send the surface back out — see
201    /// [`SurfaceTransition::leave`].
202    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            // Escape is the keyboard's version of a press outside, so a surface that answers to one answers to
267            // the other. Same rule as the in-window dismiss stack (see `dispatch_overlays`): the content gets
268            // first refusal, so a focused field blurs on the first press and the surface closes on the second,
269            // and backing out of an armed confirmation never takes the surface with it.
270            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    /// Escape is the keyboard's way out of a surface that a press outside would also close, and it only reaches
348    /// the surface when nothing inside wanted it: a focused field, an armed confirmation and an open dropdown
349    /// all cancel themselves first, and taking the whole surface down with them is the bug this guards.
350    #[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        // Start-aligned top drawer, floated 10px off every edge: the 100-wide panel sits at x in [10, 110].
418        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        // Scrim but no dismiss handler: the surface dims what is behind it and swallows the press anyway.
454        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}