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;
38const IDENTITY: [f32; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
39
40#[derive(Clone, Copy)]
41enum 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.
48fn 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
67fn 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    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
289pub struct SurfaceRoot {
290    root: NodeId,
291    content: Box<dyn LayoutItem>,
292    transition: Option<SurfaceTransition>,
293}
294
295impl SurfaceRoot {
296    pub fn new(content: Box<dyn LayoutItem>) -> Result<Self, LayoutError> {
297        let root = new_container(
298            LayoutStyle::new()
299                .flex_row()
300                .width(SizeDimension::Percent(1.0))
301                .height(SizeDimension::Percent(1.0)),
302            &[content.layout_node()],
303        )?;
304        Ok(Self {
305            root,
306            content,
307            transition: None,
308        })
309    }
310
311    pub fn animate_in(self) -> Self {
312        self.animate(SurfaceTransition::enter())
313    }
314
315    /// Drives the root from a transition the *caller* owns, so it can also send the surface back out — see
316    /// [`SurfaceTransition::leave`].
317    pub fn animate(mut self, transition: SurfaceTransition) -> Self {
318        self.transition = Some(transition);
319        self
320    }
321}
322
323impl LayoutItem for SurfaceRoot {
324    fn layout_node(&self) -> NodeId {
325        self.root
326    }
327}
328
329impl Component for SurfaceRoot {
330    fn view(&self) -> RenderNode {
331        let content = self.content.view();
332        match &self.transition {
333            Some(transition) => {
334                let (_, opacity) = enter_transform(EnterMotion::Fade, transition.get());
335                apply_enter(content, IDENTITY, opacity)
336            }
337            None => content,
338        }
339    }
340
341    fn on_event(&mut self, event: &Event) -> EventResult {
342        if let Event::WindowResized { width, height } = event {
343            mark_dirty(self.root).ok();
344            compute_layout(
345                self.root,
346                AvailableSpace::Definite(*width as f32),
347                AvailableSpace::Definite(*height as f32),
348            )
349            .ok();
350            return EventResult::Handled;
351        }
352        self.content.on_event(event)
353    }
354
355    fn debug_name(&self) -> &'static str {
356        "SurfaceRoot"
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use renderer_core::TextStyle;
364    use std::cell::Cell;
365
366    use platform_core::PointerSource;
367    use renderer_core::RectStyle;
368
369    use crate::StyledContainer;
370    use crate::context::reset_layout_runtime;
371    use crate::layout_item::box_item;
372
373    fn panel() -> Box<dyn LayoutItem> {
374        box_item(
375            StyledContainer::new(
376                LayoutStyle::new().width(100.0).height(40.0),
377                |_r| RectStyle::default(),
378                vec![],
379            )
380            .unwrap(),
381        )
382    }
383
384    fn press(x: f64, y: f64) -> Event {
385        Event::PointerPressed {
386            x,
387            y,
388            button: PointerButton::Primary,
389            source: PointerSource::Mouse,
390        }
391    }
392
393    #[test]
394    fn dismiss_fires_on_outside_press_only() {
395        reset_layout_runtime();
396        let fired = Rc::new(Cell::new(0u32));
397        let f = fired.clone();
398        let mut scaffold = SurfaceScaffold::new(
399            Edge::Top,
400            AlignItems::CENTER,
401            (20, 20, 20, 20),
402            Some(DEFAULT_SCRIM),
403            Some(Rc::new(move || f.set(f.get() + 1))),
404            panel(),
405        )
406        .unwrap();
407        scaffold.on_event(&Event::WindowResized {
408            width: 200,
409            height: 200,
410        });
411
412        scaffold.on_event(&press(100.0, 40.0));
413        assert_eq!(fired.get(), 0, "a press inside the panel must not dismiss");
414        scaffold.on_event(&press(10.0, 190.0));
415        assert_eq!(fired.get(), 1, "a press outside the panel dismisses");
416    }
417
418    /// Escape is the keyboard's way out of a surface that a press outside would also close, and it only reaches
419    /// the surface when nothing inside wanted it: a focused field, an armed confirmation and an open dropdown
420    /// all cancel themselves first, and taking the whole surface down with them is the bug this guards.
421    #[test]
422    fn escape_dismisses_only_what_the_panel_left_alone() {
423        reset_layout_runtime();
424        let fired = Rc::new(Cell::new(0u32));
425        let f = fired.clone();
426        let mut scaffold = SurfaceScaffold::new(
427            Edge::Top,
428            AlignItems::CENTER,
429            (20, 20, 20, 20),
430            Some(DEFAULT_SCRIM),
431            Some(Rc::new(move || f.set(f.get() + 1))),
432            panel(),
433        )
434        .unwrap();
435        scaffold.on_event(&Event::WindowResized {
436            width: 200,
437            height: 200,
438        });
439
440        let escape = Event::KeyPressed {
441            key: Key::Named(NamedKey::Escape),
442            modifiers: Default::default(),
443        };
444        assert_eq!(scaffold.on_event(&escape), EventResult::Handled);
445        assert_eq!(
446            fired.get(),
447            1,
448            "a plain panel lets Escape close the surface"
449        );
450
451        reset_layout_runtime();
452        let untouched = Rc::new(Cell::new(0u32));
453        let u = untouched.clone();
454        let field = crate::input::Input::new(
455            reactive_core::signal(String::new()),
456            LayoutStyle::new().width(100.0).height(30.0),
457            || TextStyle::new(14.0, Color::BLACK),
458        )
459        .unwrap()
460        .autofocus();
461        let mut focused = SurfaceScaffold::new(
462            Edge::Top,
463            AlignItems::CENTER,
464            (20, 20, 20, 20),
465            Some(DEFAULT_SCRIM),
466            Some(Rc::new(move || u.set(u.get() + 1))),
467            box_item(field),
468        )
469        .unwrap();
470        focused.on_event(&Event::WindowResized {
471            width: 200,
472            height: 200,
473        });
474        focused.on_event(&press(100.0, 40.0));
475        focused.on_event(&escape);
476        assert_eq!(
477            untouched.get(),
478            0,
479            "a field that claims Escape to release its own focus keeps the surface up"
480        );
481    }
482
483    #[test]
484    fn cross_axis_margin_insets_the_panel_from_the_side_edges() {
485        reset_layout_runtime();
486        let fired = Rc::new(Cell::new(0u32));
487        let f = fired.clone();
488        // Start-aligned top drawer, floated 10px off every edge: the 100-wide panel sits at x in [10, 110].
489        let mut scaffold = SurfaceScaffold::new(
490            Edge::Top,
491            AlignItems::START,
492            (30, 10, 10, 10),
493            Some(DEFAULT_SCRIM),
494            Some(Rc::new(move || f.set(f.get() + 1))),
495            panel(),
496        )
497        .unwrap();
498        scaffold.on_event(&Event::WindowResized {
499            width: 200,
500            height: 200,
501        });
502
503        scaffold.on_event(&press(50.0, 40.0));
504        assert_eq!(
505            fired.get(),
506            0,
507            "a press on the inset panel must not dismiss"
508        );
509        scaffold.on_event(&press(4.0, 40.0));
510        assert_eq!(
511            fired.get(),
512            1,
513            "a press in the left gap (before the inset) dismisses"
514        );
515        scaffold.on_event(&press(150.0, 40.0));
516        assert_eq!(fired.get(), 2, "a press in the right gap dismisses");
517    }
518
519    #[test]
520    fn no_dismiss_when_not_configured() {
521        reset_layout_runtime();
522        let fired = Rc::new(Cell::new(0u32));
523        let f = fired.clone();
524        // Scrim but no dismiss handler: the surface dims what is behind it and swallows the press anyway.
525        let mut scaffold = SurfaceScaffold::new(
526            Edge::Top,
527            AlignItems::CENTER,
528            (0, 0, 0, 0),
529            Some(DEFAULT_SCRIM),
530            None,
531            panel(),
532        )
533        .unwrap();
534        let _ = &f;
535        scaffold.on_event(&Event::WindowResized {
536            width: 200,
537            height: 200,
538        });
539
540        scaffold.on_event(&press(10.0, 190.0));
541        assert_eq!(
542            fired.get(),
543            0,
544            "no dismiss must fire when dismiss_on_outside is off"
545        );
546    }
547    #[test]
548    fn enter_transform_fade_is_opacity_only() {
549        assert_eq!(enter_transform(EnterMotion::Fade, 0.0), (IDENTITY, 0.0));
550        assert_eq!(enter_transform(EnterMotion::Fade, 0.5), (IDENTITY, 0.5));
551        assert_eq!(enter_transform(EnterMotion::Fade, 1.0), (IDENTITY, 1.0));
552    }
553
554    #[test]
555    fn enter_transform_slide_offsets_from_edge_then_settles() {
556        let (m, o) = enter_transform(EnterMotion::Slide(Edge::Top), 0.0);
557        assert_eq!(o, 0.0);
558        assert_eq!(m[5], -SLIDE_DISTANCE, "top slides down from above");
559        assert_eq!(
560            enter_transform(EnterMotion::Slide(Edge::Top), 1.0),
561            (IDENTITY, 1.0)
562        );
563        assert_eq!(
564            enter_transform(EnterMotion::Slide(Edge::Bottom), 0.0).0[5],
565            SLIDE_DISTANCE
566        );
567        assert_eq!(
568            enter_transform(EnterMotion::Slide(Edge::Left), 0.0).0[4],
569            -SLIDE_DISTANCE
570        );
571        assert_eq!(
572            enter_transform(EnterMotion::Slide(Edge::Right), 0.0).0[4],
573            SLIDE_DISTANCE
574        );
575    }
576}