Skip to main content

telar_ui_core/
surface.rs

1use std::cell::{Cell, RefCell};
2use std::rc::Rc;
3use std::time::Duration;
4
5use geometry_core::{Rect, Transform};
6use layout_core::{
7    AlignItems, AvailableSpace, JustifyContent, LayoutError, LayoutStyle, NodeId, SizeDimension,
8};
9use motion_core::{Animated, Easing, tween};
10use platform_core::{Event, Key, NamedKey, PointerButton};
11use reactive_core::RwSignal;
12use renderer_core::{Color, RectStyle, TextStyle};
13use ui_tree::{Component, EventResult, RenderNode};
14
15use crate::context::{compute_layout, mark_dirty, new_container, track_layout};
16use crate::layout_item::{LayoutItem, box_item};
17use crate::styled_container::StyledContainer;
18use crate::text::Text;
19
20/// What kind of secondary surface a placement describes. A backend maps the role to its own surface
21/// primitives (a layer-shell backend picks a layer + namespace; a windowed backend a child window or an
22/// in-window portal). Roles carry no behaviour of their own — the explicit [`SurfacePlacement`] fields do.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum SurfaceRole {
25    /// A panel that slides off a bar/edge, dimming what's behind it.
26    Drawer,
27    /// A transient, positioned popup (a notification, a menu detached from its trigger).
28    Popup,
29    /// A brief, non-interactive status flash (volume/brightness), auto-dismissed.
30    Osd,
31    /// A free-floating window with its own title/close affordances.
32    Float,
33    /// A modal that owns the screen while it is up: a launcher, a command palette, a session menu. Unlike a
34    /// [`Drawer`](Self::Drawer) it isn't anchored to an edge, and unlike a [`Float`](Self::Float) it expects to
35    /// take the keyboard outright — the user is typing into it, not at whatever is behind it.
36    Overlay,
37}
38
39/// How much of the keyboard a surface needs.
40///
41/// The distinction matters because it decides who receives a keystroke *before* any click. A panel with a text
42/// field can wait to be clicked into ([`OnDemand`](Self::OnDemand)); a launcher cannot — it opens on a keybind
43/// and the next keystroke is already its first search character, so it has to hold the keyboard from the moment
44/// it maps ([`Exclusive`](Self::Exclusive)). Asking for more than is needed is not free: a surface holding the
45/// keyboard takes it from the focused window.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47pub enum KeyboardMode {
48    /// Display-only; never takes keyboard focus.
49    #[default]
50    None,
51    /// May be given focus on interaction, e.g. a click into a text field.
52    OnDemand,
53    /// Holds the keyboard for as long as it is mapped.
54    Exclusive,
55}
56
57/// The screen edge (or centre) a surface hugs. The cross axis is aligned by [`SurfaceAlign`].
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum SurfaceAnchor {
60    Top,
61    Bottom,
62    Left,
63    Right,
64    Center,
65}
66
67/// Cross-axis alignment along the anchored edge (e.g. left/centre/right for a top-anchored surface).
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum SurfaceAlign {
70    Start,
71    Center,
72    End,
73}
74
75/// A surface's size: a fixed logical pixel box, or derived from its content.
76#[derive(Debug, Clone, Copy, PartialEq)]
77pub enum SurfaceSize {
78    Fixed(u32, u32),
79    Auto,
80}
81
82/// A backend-agnostic description of a secondary surface: where it sits, how big it is, and how it
83/// behaves (scrim, outside-dismiss, auto-timeout). The intent lives here; a backend derives its own
84/// surface config from it. Reusable by a windowed app (as an in-window portal) and by a shell (as a real
85/// layer-shell surface) alike.
86#[derive(Debug, Clone)]
87pub struct SurfacePlacement {
88    pub role: SurfaceRole,
89    pub anchor: SurfaceAnchor,
90    pub align: SurfaceAlign,
91    pub size: SurfaceSize,
92    /// Gap from the screen edges, as `(top, right, bottom, left)`. A full-screen scrim scaffold applies the
93    /// whole tuple as padding (so the panel floats off every edge, not just the anchored one); a
94    /// directly-anchored surface applies it as the compositor margin.
95    pub margin: (i32, i32, i32, i32),
96    /// Dim (and, with `dismiss_on_outside`, capture) the area behind the panel.
97    pub scrim: bool,
98    /// A press outside the panel dismisses the surface.
99    pub dismiss_on_outside: bool,
100    /// Auto-dismiss after this long; `None` keeps it until closed explicitly.
101    pub timeout: Option<Duration>,
102    /// The surface passes pointer input through to whatever is beneath it (a click-through OSD).
103    pub input_transparent: bool,
104    /// How much of the keyboard the surface needs; a backend maps this to its own focus model (e.g. layer-shell
105    /// keyboard interactivity). Defaults to [`KeyboardMode::None`], so a panel is display-only and never steals
106    /// the keyboard.
107    pub keyboard: KeyboardMode,
108    /// The monitor to place the surface on by name; `None` = the active/default output.
109    pub output: Option<String>,
110}
111
112impl SurfacePlacement {
113    pub fn new(role: SurfaceRole, anchor: SurfaceAnchor) -> Self {
114        Self {
115            role,
116            anchor,
117            align: SurfaceAlign::Center,
118            size: SurfaceSize::Auto,
119            margin: (0, 0, 0, 0),
120            scrim: false,
121            dismiss_on_outside: false,
122            timeout: None,
123            input_transparent: false,
124            keyboard: KeyboardMode::None,
125            output: None,
126        }
127    }
128
129    /// A modal that owns the screen: centred, scrimmed, dismissed by a press outside, and holding the keyboard
130    /// from the moment it maps so the first keystroke after the keybind is already typed into it.
131    pub fn overlay() -> Self {
132        Self {
133            scrim: true,
134            dismiss_on_outside: true,
135            keyboard: KeyboardMode::Exclusive,
136            ..Self::new(SurfaceRole::Overlay, SurfaceAnchor::Center)
137        }
138    }
139
140    pub fn drawer(anchor: SurfaceAnchor) -> Self {
141        Self {
142            scrim: true,
143            dismiss_on_outside: true,
144            ..Self::new(SurfaceRole::Drawer, anchor)
145        }
146    }
147
148    pub fn osd() -> Self {
149        Self {
150            input_transparent: true,
151            ..Self::new(SurfaceRole::Osd, SurfaceAnchor::Top)
152        }
153    }
154
155    pub fn float() -> Self {
156        Self::new(SurfaceRole::Float, SurfaceAnchor::Center)
157    }
158
159    pub fn align(mut self, align: SurfaceAlign) -> Self {
160        self.align = align;
161        self
162    }
163
164    pub fn size(mut self, size: SurfaceSize) -> Self {
165        self.size = size;
166        self
167    }
168
169    pub fn margin(mut self, margin: (i32, i32, i32, i32)) -> Self {
170        self.margin = margin;
171        self
172    }
173
174    pub fn inset(mut self, px: i32) -> Self {
175        let (t, r, b, l) = self.margin;
176        self.margin = match self.anchor {
177            SurfaceAnchor::Top => (px, r, b, l),
178            SurfaceAnchor::Bottom => (t, r, px, l),
179            SurfaceAnchor::Left => (t, r, b, px),
180            SurfaceAnchor::Right => (t, px, b, l),
181            SurfaceAnchor::Center => (t, r, b, l),
182        };
183        self
184    }
185
186    pub fn scrim(mut self, scrim: bool) -> Self {
187        self.scrim = scrim;
188        self
189    }
190
191    pub fn dismiss_on_outside(mut self, dismiss: bool) -> Self {
192        self.dismiss_on_outside = dismiss;
193        self
194    }
195
196    pub fn timeout(mut self, timeout: Duration) -> Self {
197        self.timeout = Some(timeout);
198        self
199    }
200
201    pub fn input_transparent(mut self, transparent: bool) -> Self {
202        self.input_transparent = transparent;
203        self
204    }
205
206    /// Opt the surface into focus-on-interaction, for panels that host editable text (a search box, a note
207    /// title). Sugar for [`keyboard_mode`](Self::keyboard_mode) with
208    /// [`OnDemand`](KeyboardMode::OnDemand)/[`None`](KeyboardMode::None).
209    pub fn keyboard(mut self, wants_keyboard: bool) -> Self {
210        self.keyboard = if wants_keyboard {
211            KeyboardMode::OnDemand
212        } else {
213            KeyboardMode::None
214        };
215        self
216    }
217
218    /// Sets exactly how much of the keyboard the surface takes.
219    pub fn keyboard_mode(mut self, mode: KeyboardMode) -> Self {
220        self.keyboard = mode;
221        self
222    }
223
224    /// Whether the surface takes keyboard focus at all.
225    pub fn wants_keyboard(&self) -> bool {
226        self.keyboard != KeyboardMode::None
227    }
228
229    pub fn output(mut self, output: Option<String>) -> Self {
230        self.output = output;
231        self
232    }
233
234    /// Whether the surface needs a full-viewport scaffold (to draw a scrim or catch outside presses)
235    /// rather than being anchored directly at its content size.
236    pub fn needs_scaffold(&self) -> bool {
237        self.scrim || self.dismiss_on_outside
238    }
239}
240
241/// The default scrim wash: ~35 % black over the content behind a drawer/modal. Rendered as a fill (not an
242/// opacity layer) so the panel above it stays fully opaque.
243pub const DEFAULT_SCRIM: Color = Color::rgba(0.0, 0.0, 0.0, 0.35);
244
245fn cross_align(align: SurfaceAlign) -> AlignItems {
246    match align {
247        SurfaceAlign::Start => AlignItems::START,
248        SurfaceAlign::Center => AlignItems::CENTER,
249        SurfaceAlign::End => AlignItems::END,
250    }
251}
252
253/// Enter-animation duration and slide travel.
254const ENTER_MS: u64 = 200;
255const SLIDE_DISTANCE: f32 = 24.0;
256const IDENTITY: [f32; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
257
258#[derive(Clone, Copy)]
259enum EnterMotion {
260    Slide(SurfaceAnchor),
261    Fade,
262}
263
264/// The transform matrix and opacity for an enter animation at `progress` (0 = just opened, 1 = settled).
265/// A slide starts `SLIDE_DISTANCE` off its edge and eases to rest; both forms fade in.
266fn enter_transform(motion: EnterMotion, progress: f32) -> ([f32; 6], f32) {
267    let p = progress.clamp(0.0, 1.0);
268    let opacity = p;
269    match motion {
270        EnterMotion::Fade => (IDENTITY, opacity),
271        EnterMotion::Slide(anchor) => {
272            let d = SLIDE_DISTANCE * (1.0 - p);
273            let (dx, dy) = match anchor {
274                SurfaceAnchor::Top => (0.0, -d),
275                SurfaceAnchor::Bottom => (0.0, d),
276                SurfaceAnchor::Left => (-d, 0.0),
277                SurfaceAnchor::Right => (d, 0.0),
278                SurfaceAnchor::Center => (0.0, 0.0),
279            };
280            (Transform::translate(dx, dy).to_array(), opacity)
281        }
282    }
283}
284
285fn apply_enter(node: RenderNode, matrix: [f32; 6], opacity: f32) -> RenderNode {
286    let faded = if opacity < 1.0 {
287        RenderNode::layer(opacity, 0.0, [node])
288    } else {
289        node
290    };
291    if matrix == IDENTITY {
292        faded
293    } else {
294        RenderNode::transform_with(matrix, [faded])
295    }
296}
297
298/// The one progress value a surface's arrival and departure share: 0 is off its edge and transparent, 1 is
299/// settled. Opening runs it to 1; [`leave`](Self::leave) runs it back to 0, so the exit is the entrance
300/// reversed rather than a second animation that has to be kept in step with the first.
301///
302/// A backend that can hold a closing surface on screen for [`duration`](Self::duration) is what makes the exit
303/// half visible; one that cannot simply never calls `leave`, and the surface disappears as it always did.
304#[derive(Clone)]
305pub struct SurfaceTransition {
306    progress: Animated<f32>,
307    duration: Duration,
308}
309
310impl SurfaceTransition {
311    /// A transition already on its way in. Constructed away from its goal and retargeted at once, never *at*
312    /// it: an `Animated` born settled registers with no ticker, so nothing would schedule the frames that carry
313    /// it in.
314    pub fn enter() -> Self {
315        let duration = Duration::from_millis(ENTER_MS);
316        let progress = Animated::new(0.0, tween(duration, Easing::EaseOut));
317        progress.retarget(1.0);
318        Self { progress, duration }
319    }
320
321    /// Sends the surface back the way it came. The caller is responsible for keeping it on screen for
322    /// [`duration`](Self::duration) — otherwise this animates a surface that has already been torn down.
323    pub fn leave(&self) {
324        self.progress.retarget(0.0);
325    }
326
327    /// How long either half takes, and therefore how long a closing surface has to stay mapped.
328    pub fn duration(&self) -> Duration {
329        self.duration
330    }
331
332    fn get(&self) -> f32 {
333        self.progress.get()
334    }
335}
336
337/// A full-viewport scaffold that positions a panel against a screen edge, optionally dims the area behind
338/// it, and dismisses on a press outside the panel. It is the reusable body of a drawer/modal: a shell
339/// mounts it as the root of a full-screen layer-shell surface, and a windowed app can mount it in-tree as
340/// an in-window portal — both get the same positioning and dismiss behaviour.
341///
342/// Like other full-surface roots it (re)computes its own layout on `WindowResized`; the runner synthesizes
343/// an initial one on resume, so the scaffold is laid out before its first frame.
344pub struct SurfaceScaffold {
345    root: NodeId,
346    panel_rect: Option<RwSignal<Rect>>,
347    root_rect: Option<RwSignal<Rect>>,
348    content: Box<dyn LayoutItem>,
349    scrim: Option<Color>,
350    dismiss: Option<Rc<dyn Fn()>>,
351    anchor: SurfaceAnchor,
352    transition: Option<SurfaceTransition>,
353}
354
355impl SurfaceScaffold {
356    pub fn new(
357        placement: &SurfacePlacement,
358        content: Box<dyn LayoutItem>,
359        dismiss: Option<Rc<dyn Fn()>>,
360    ) -> Result<Self, LayoutError> {
361        let panel_node = content.layout_node();
362        let (mt, mr, mb, ml) = placement.margin;
363        let cross = cross_align(placement.align);
364        // The full margin becomes padding so the panel floats off every screen edge, not just the anchored one;
365        // the per-anchor direction/justify then pins it to its edge within that padded box.
366        let base = LayoutStyle::new()
367            .width(SizeDimension::Percent(1.0))
368            .height(SizeDimension::Percent(1.0))
369            .padding_top(mt as f32)
370            .padding_right(mr as f32)
371            .padding_bottom(mb as f32)
372            .padding_left(ml as f32);
373        let style = match placement.anchor {
374            SurfaceAnchor::Top => base
375                .flex_column()
376                .justify_content(JustifyContent::START)
377                .align_items(cross),
378            SurfaceAnchor::Bottom => base
379                .flex_column()
380                .justify_content(JustifyContent::END)
381                .align_items(cross),
382            SurfaceAnchor::Left => base
383                .flex_row()
384                .justify_content(JustifyContent::START)
385                .align_items(cross),
386            SurfaceAnchor::Right => base
387                .flex_row()
388                .justify_content(JustifyContent::END)
389                .align_items(cross),
390            SurfaceAnchor::Center => base
391                .flex_column()
392                .justify_content(JustifyContent::CENTER)
393                .align_items(AlignItems::CENTER),
394        };
395        let root = new_container(style, &[panel_node])?;
396        let panel_rect = track_layout(panel_node);
397        let root_rect = track_layout(root);
398        let dismiss = if placement.dismiss_on_outside {
399            dismiss
400        } else {
401            None
402        };
403        Ok(Self {
404            root,
405            panel_rect,
406            root_rect,
407            content,
408            scrim: placement.scrim.then_some(DEFAULT_SCRIM),
409            dismiss,
410            anchor: placement.anchor,
411            transition: None,
412        })
413    }
414
415    pub fn animate_in(self) -> Self {
416        self.animate(SurfaceTransition::enter())
417    }
418
419    /// Drives the scaffold from a transition the *caller* owns, so it can also send the surface back out — see
420    /// [`SurfaceTransition::leave`].
421    pub fn animate(mut self, transition: SurfaceTransition) -> Self {
422        self.transition = Some(transition);
423        self
424    }
425}
426
427impl LayoutItem for SurfaceScaffold {
428    fn layout_node(&self) -> NodeId {
429        self.root
430    }
431}
432
433impl Component for SurfaceScaffold {
434    fn view(&self) -> RenderNode {
435        let content = self.content.view();
436        let (matrix, opacity) = match &self.transition {
437            Some(transition) => enter_transform(EnterMotion::Slide(self.anchor), transition.get()),
438            None => (IDENTITY, 1.0),
439        };
440        let panel = apply_enter(content, matrix, opacity);
441        match (self.scrim, self.root_rect.as_ref()) {
442            (Some(color), Some(rect)) => {
443                let scrim = apply_enter(
444                    RenderNode::rect(rect.get(), RectStyle::filled(color, 0.0)),
445                    IDENTITY,
446                    opacity,
447                );
448                RenderNode::group([scrim, panel])
449            }
450            _ => panel,
451        }
452    }
453
454    fn on_event(&mut self, event: &Event) -> EventResult {
455        match event {
456            Event::WindowResized { width, height } => {
457                mark_dirty(self.root).ok();
458                compute_layout(
459                    self.root,
460                    AvailableSpace::Definite(*width as f32),
461                    AvailableSpace::Definite(*height as f32),
462                )
463                .ok();
464                EventResult::Handled
465            }
466            Event::PointerPressed {
467                x,
468                y,
469                button: PointerButton::Primary,
470                ..
471            } => {
472                let inside = self
473                    .panel_rect
474                    .as_ref()
475                    .map(|r| r.get().contains(*x as f32, *y as f32))
476                    .unwrap_or(false);
477                match (inside, &self.dismiss) {
478                    (false, Some(dismiss)) => {
479                        dismiss();
480                        EventResult::Handled
481                    }
482                    _ => self.content.on_event(event),
483                }
484            }
485            // Escape is the keyboard's version of a press outside, so a surface that answers to one answers to
486            // the other. Same rule as the in-window dismiss stack (see `dispatch_overlays`): the content gets
487            // first refusal, so a focused field blurs on the first press and the surface closes on the second,
488            // and backing out of an armed confirmation never takes the surface with it.
489            Event::KeyPressed {
490                key: Key::Named(NamedKey::Escape),
491                ..
492            } => match (self.content.on_event(event), &self.dismiss) {
493                (EventResult::Ignored, Some(dismiss)) => {
494                    dismiss();
495                    EventResult::Handled
496                }
497                (result, _) => result,
498            },
499            _ => self.content.on_event(event),
500        }
501    }
502
503    fn debug_name(&self) -> &'static str {
504        "SurfaceScaffold"
505    }
506}
507
508pub struct SurfaceRoot {
509    root: NodeId,
510    content: Box<dyn LayoutItem>,
511    transition: Option<SurfaceTransition>,
512}
513
514impl SurfaceRoot {
515    pub fn new(content: Box<dyn LayoutItem>) -> Result<Self, LayoutError> {
516        let root = new_container(
517            LayoutStyle::new()
518                .flex_row()
519                .width(SizeDimension::Percent(1.0))
520                .height(SizeDimension::Percent(1.0)),
521            &[content.layout_node()],
522        )?;
523        Ok(Self {
524            root,
525            content,
526            transition: None,
527        })
528    }
529
530    pub fn animate_in(self) -> Self {
531        self.animate(SurfaceTransition::enter())
532    }
533
534    /// Drives the root from a transition the *caller* owns, so it can also send the surface back out — see
535    /// [`SurfaceTransition::leave`].
536    pub fn animate(mut self, transition: SurfaceTransition) -> Self {
537        self.transition = Some(transition);
538        self
539    }
540}
541
542impl LayoutItem for SurfaceRoot {
543    fn layout_node(&self) -> NodeId {
544        self.root
545    }
546}
547
548impl Component for SurfaceRoot {
549    fn view(&self) -> RenderNode {
550        let content = self.content.view();
551        match &self.transition {
552            Some(transition) => {
553                let (_, opacity) = enter_transform(EnterMotion::Fade, transition.get());
554                apply_enter(content, IDENTITY, opacity)
555            }
556            None => content,
557        }
558    }
559
560    fn on_event(&mut self, event: &Event) -> EventResult {
561        if let Event::WindowResized { width, height } = event {
562            mark_dirty(self.root).ok();
563            compute_layout(
564                self.root,
565                AvailableSpace::Definite(*width as f32),
566                AvailableSpace::Definite(*height as f32),
567            )
568            .ok();
569            return EventResult::Handled;
570        }
571        self.content.on_event(event)
572    }
573
574    fn debug_name(&self) -> &'static str {
575        "SurfaceRoot"
576    }
577}
578
579#[derive(Debug, Clone, Copy)]
580pub struct SurfaceFrameStyle {
581    pub background: Color,
582    pub title_bar: Color,
583    pub title_text: Color,
584    pub close: Color,
585    pub radius: f32,
586    pub font_size: f32,
587}
588
589/// The smallest a frame will ask to become. A window dragged to nothing is a window the user cannot get hold of
590/// again — its own grip goes with it.
591pub const MIN_FRAME_SIZE: (f32, f32) = (180.0, 120.0);
592
593/// The corner grip's side, in logical pixels. Big enough to hit without aiming, small enough not to read as
594/// content.
595const GRIP_SIZE: f32 = 14.0;
596
597/// The rect a grip measures the frame against. It is a cell rather than a signal because the grip has to exist
598/// before the row that holds it, and that row before the card that holds *both* — so the one rect the grip needs
599/// is the one thing it cannot be handed at construction. Filled in as soon as the card exists.
600type DeferredRect = Rc<RefCell<Option<RwSignal<Rect>>>>;
601
602/// A resize grip for the bottom-right corner of a frame, reporting the size the *surface* should become.
603///
604/// The arithmetic is the whole of it. `on_drag` reports where the pointer is **inside the grip**, so the grip's
605/// own laid-out origin has to be added back to reach surface space — and then the grab offset, the distance from
606/// the pointer to the corner when the drag began, has to come off it, or the corner jumps to the cursor the
607/// instant it is touched. The offset is latched once per drag rather than recomputed, because the card it was
608/// measured against is resizing underneath the gesture.
609fn resize_grip(
610    color: Color,
611    card_rect: DeferredRect,
612    resize: Rc<dyn Fn(f32, f32)>,
613) -> Result<Box<dyn LayoutItem>, LayoutError> {
614    let grip = StyledContainer::new(
615        LayoutStyle::new().width(GRIP_SIZE).height(GRIP_SIZE),
616        move |_| RectStyle::filled(color, 2.0),
617        vec![],
618    )?;
619    let grip_rect = track_layout(grip.layout_node());
620    let grab: Rc<Cell<Option<(f32, f32)>>> = Rc::new(Cell::new(None));
621    let release = Rc::clone(&grab);
622    Ok(box_item(
623        grip.on_drag(move |local_x, local_y| {
624            let (Some(grip_rect), Some(card_rect)) = (&grip_rect, card_rect.borrow().clone())
625            else {
626                return;
627            };
628            let (grip, card) = (grip_rect.get(), card_rect.get());
629            let (x, y) = (grip.x + local_x, grip.y + local_y);
630            let (offset_x, offset_y) = match grab.get() {
631                Some(offset) => offset,
632                None => {
633                    let offset = (x - (card.x + card.width), y - (card.y + card.height));
634                    grab.set(Some(offset));
635                    offset
636                }
637            };
638            resize(
639                (x - offset_x - card.x).max(MIN_FRAME_SIZE.0),
640                (y - offset_y - card.y).max(MIN_FRAME_SIZE.1),
641            );
642        })
643        .on_drag_end(move |_, _| release.set(None)),
644    ))
645}
646
647/// A titled, closable window frame around `body`.
648///
649/// `resize` opts the frame into a corner grip: it is handed the size the surface should take, in logical
650/// pixels, on every move of that grip. A backend that can renegotiate a surface's size wires it up; one that
651/// cannot passes `None` and the grip is not drawn, rather than drawn and inert.
652pub fn surface_frame(
653    title: impl Into<String>,
654    style: SurfaceFrameStyle,
655    close: std::rc::Rc<dyn Fn()>,
656    body: Box<dyn LayoutItem>,
657    resize: Option<Rc<dyn Fn(f32, f32)>>,
658) -> Result<Box<dyn LayoutItem>, LayoutError> {
659    let title = title.into();
660    let title_color = style.title_text;
661    let font_size = style.font_size;
662    let title_label = box_item(Text::auto(
663        move || title.clone(),
664        LayoutStyle::new(),
665        move || TextStyle::new(font_size, title_color),
666    )?);
667
668    let close_color = style.close;
669    let close_label = box_item(Text::auto(
670        || "\u{2715}".to_string(),
671        LayoutStyle::new(),
672        move || TextStyle::new(font_size, close_color),
673    )?);
674    let close_button = box_item(
675        StyledContainer::new(
676            LayoutStyle::new()
677                .align_items(AlignItems::CENTER)
678                .justify_content(JustifyContent::CENTER)
679                .padding_horizontal(8.0)
680                .padding_vertical(2.0),
681            |_| RectStyle::default(),
682            vec![close_label],
683        )?
684        .on_press(move || close()),
685    );
686
687    let title_bar_color = style.title_bar;
688    let title_bar = box_item(StyledContainer::new(
689        LayoutStyle::new()
690            .flex_row()
691            .align_items(AlignItems::CENTER)
692            .justify_content(JustifyContent::SPACE_BETWEEN)
693            .width(SizeDimension::Percent(1.0))
694            .padding_horizontal(12.0)
695            .padding_vertical(8.0),
696        move |_| RectStyle::filled(title_bar_color, 0.0),
697        vec![title_label, close_button],
698    )?);
699
700    // A flex item may not shrink below its content unless you say so, and an application body sized to fill the window (the settings page area is a scroll leaf with a definite height) otherwise refuses to give up a single pixel and pushes the grip row off the bottom of the surface — a resize affordance that exists, lays out, and is never on screen.
701    let body_area = box_item(StyledContainer::new(
702        LayoutStyle::new()
703            .flex_column()
704            .flex_grow(1.0)
705            .min_height(0.0)
706            .width(SizeDimension::Percent(1.0))
707            .align_items(AlignItems::CENTER)
708            .justify_content(JustifyContent::CENTER)
709            .padding_all(12.0),
710        |_| RectStyle::default(),
711        vec![body],
712    )?);
713
714    let card_rect: DeferredRect = Rc::new(RefCell::new(None));
715    let mut children = vec![title_bar, body_area];
716    if let Some(resize) = resize {
717        children.push(box_item(StyledContainer::new(
718            LayoutStyle::new()
719                .flex_row()
720                .width(SizeDimension::Percent(1.0))
721                .flex_shrink(0.0)
722                .justify_content(JustifyContent::END)
723                .padding_horizontal(4.0)
724                .padding_bottom(4.0),
725            |_| RectStyle::default(),
726            vec![resize_grip(style.close, Rc::clone(&card_rect), resize)?],
727        )?));
728    }
729
730    let background = style.background;
731    let radius = style.radius;
732    let card = StyledContainer::new(
733        LayoutStyle::new()
734            .flex_column()
735            .width(SizeDimension::Percent(1.0))
736            .height(SizeDimension::Percent(1.0)),
737        move |_| RectStyle::filled(background, radius),
738        children,
739    )?;
740    *card_rect.borrow_mut() = track_layout(card.layout_node());
741    Ok(box_item(card))
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747    use std::cell::Cell;
748
749    use platform_core::PointerSource;
750    use renderer_core::RectStyle;
751
752    use crate::StyledContainer;
753    use crate::context::reset_layout_runtime;
754    use crate::layout_item::box_item;
755
756    fn panel() -> Box<dyn LayoutItem> {
757        box_item(
758            StyledContainer::new(
759                LayoutStyle::new().width(100.0).height(40.0),
760                |_r| RectStyle::default(),
761                vec![],
762            )
763            .unwrap(),
764        )
765    }
766
767    fn press(x: f64, y: f64) -> Event {
768        Event::PointerPressed {
769            x,
770            y,
771            button: PointerButton::Primary,
772            source: PointerSource::Mouse,
773        }
774    }
775
776    #[test]
777    fn dismiss_fires_on_outside_press_only() {
778        reset_layout_runtime();
779        let fired = Rc::new(Cell::new(0u32));
780        let f = fired.clone();
781        let placement = SurfacePlacement::drawer(SurfaceAnchor::Top).inset(20);
782        let mut scaffold = SurfaceScaffold::new(
783            &placement,
784            panel(),
785            Some(Rc::new(move || f.set(f.get() + 1))),
786        )
787        .unwrap();
788        scaffold.on_event(&Event::WindowResized {
789            width: 200,
790            height: 200,
791        });
792
793        scaffold.on_event(&press(100.0, 40.0));
794        assert_eq!(fired.get(), 0, "a press inside the panel must not dismiss");
795        scaffold.on_event(&press(10.0, 190.0));
796        assert_eq!(fired.get(), 1, "a press outside the panel dismisses");
797    }
798
799    /// Escape is the keyboard's way out of a surface that a press outside would also close, and it only reaches
800    /// the surface when nothing inside wanted it: a focused field, an armed confirmation and an open dropdown
801    /// all cancel themselves first, and taking the whole surface down with them is the bug this guards.
802    #[test]
803    fn escape_dismisses_only_what_the_panel_left_alone() {
804        reset_layout_runtime();
805        let fired = Rc::new(Cell::new(0u32));
806        let f = fired.clone();
807        let placement = SurfacePlacement::drawer(SurfaceAnchor::Top).inset(20);
808        let mut scaffold = SurfaceScaffold::new(
809            &placement,
810            panel(),
811            Some(Rc::new(move || f.set(f.get() + 1))),
812        )
813        .unwrap();
814        scaffold.on_event(&Event::WindowResized {
815            width: 200,
816            height: 200,
817        });
818
819        let escape = Event::KeyPressed {
820            key: Key::Named(NamedKey::Escape),
821            modifiers: Default::default(),
822        };
823        assert_eq!(scaffold.on_event(&escape), EventResult::Handled);
824        assert_eq!(
825            fired.get(),
826            1,
827            "a plain panel lets Escape close the surface"
828        );
829
830        reset_layout_runtime();
831        let untouched = Rc::new(Cell::new(0u32));
832        let u = untouched.clone();
833        let field = crate::input::Input::new(
834            reactive_core::signal(String::new()),
835            LayoutStyle::new().width(100.0).height(30.0),
836            || TextStyle::new(14.0, Color::BLACK),
837        )
838        .unwrap()
839        .autofocus();
840        let mut focused = SurfaceScaffold::new(
841            &placement,
842            box_item(field),
843            Some(Rc::new(move || u.set(u.get() + 1))),
844        )
845        .unwrap();
846        focused.on_event(&Event::WindowResized {
847            width: 200,
848            height: 200,
849        });
850        focused.on_event(&press(100.0, 40.0));
851        focused.on_event(&escape);
852        assert_eq!(
853            untouched.get(),
854            0,
855            "a field that claims Escape to release its own focus keeps the surface up"
856        );
857    }
858
859    #[test]
860    fn cross_axis_margin_insets_the_panel_from_the_side_edges() {
861        reset_layout_runtime();
862        let fired = Rc::new(Cell::new(0u32));
863        let f = fired.clone();
864        // Start-aligned top drawer, floated 10px off every edge: the 100-wide panel sits at x in [10, 110].
865        let placement = SurfacePlacement::drawer(SurfaceAnchor::Top)
866            .align(SurfaceAlign::Start)
867            .margin((30, 10, 10, 10));
868        let mut scaffold = SurfaceScaffold::new(
869            &placement,
870            panel(),
871            Some(Rc::new(move || f.set(f.get() + 1))),
872        )
873        .unwrap();
874        scaffold.on_event(&Event::WindowResized {
875            width: 200,
876            height: 200,
877        });
878
879        scaffold.on_event(&press(50.0, 40.0));
880        assert_eq!(
881            fired.get(),
882            0,
883            "a press on the inset panel must not dismiss"
884        );
885        scaffold.on_event(&press(4.0, 40.0));
886        assert_eq!(
887            fired.get(),
888            1,
889            "a press in the left gap (before the inset) dismisses"
890        );
891        scaffold.on_event(&press(150.0, 40.0));
892        assert_eq!(fired.get(), 2, "a press in the right gap dismisses");
893    }
894
895    #[test]
896    fn no_dismiss_when_not_configured() {
897        reset_layout_runtime();
898        let fired = Rc::new(Cell::new(0u32));
899        let f = fired.clone();
900        let placement = SurfacePlacement::new(SurfaceRole::Drawer, SurfaceAnchor::Top).scrim(true);
901        let mut scaffold = SurfaceScaffold::new(
902            &placement,
903            panel(),
904            Some(Rc::new(move || f.set(f.get() + 1))),
905        )
906        .unwrap();
907        scaffold.on_event(&Event::WindowResized {
908            width: 200,
909            height: 200,
910        });
911
912        scaffold.on_event(&press(10.0, 190.0));
913        assert_eq!(
914            fired.get(),
915            0,
916            "no dismiss must fire when dismiss_on_outside is off"
917        );
918    }
919
920    /// The grip's whole job is arithmetic, and every part of it is invisible until it is wrong.
921    ///
922    /// `on_drag` reports a position *local to the grip*, so a grip that forgot to add its own origin back would
923    /// resize the window to about 14×14 the moment it was touched. And the grab offset — the distance from the
924    /// pointer to the corner when the drag began — is what stops the corner teleporting to the cursor on the
925    /// first event: press the middle of the grip and the window must not change size at all.
926    #[test]
927    fn the_grip_resizes_by_the_distance_dragged_not_to_the_pointer() {
928        use std::cell::RefCell;
929        reset_layout_runtime();
930
931        let asked: Rc<RefCell<Vec<(f32, f32)>>> = Rc::new(RefCell::new(Vec::new()));
932        let sink = Rc::clone(&asked);
933        let style = SurfaceFrameStyle {
934            background: Color::TRANSPARENT,
935            title_bar: Color::TRANSPARENT,
936            title_text: Color::TRANSPARENT,
937            close: Color::TRANSPARENT,
938            radius: 0.0,
939            font_size: 12.0,
940        };
941        let mut frame = surface_frame(
942            "Settings",
943            style,
944            Rc::new(|| {}),
945            panel(),
946            Some(Rc::new(move |w, h| sink.borrow_mut().push((w, h)))),
947        )
948        .unwrap();
949        compute_layout(
950            frame.layout_node(),
951            AvailableSpace::Definite(400.0),
952            AvailableSpace::Definite(300.0),
953        )
954        .unwrap();
955
956        // The grip sits at the card's bottom-right, inset by the row's padding.
957        let grip = Rect {
958            x: 400.0 - 4.0 - GRIP_SIZE,
959            y: 300.0 - 4.0 - GRIP_SIZE,
960            width: GRIP_SIZE,
961            height: GRIP_SIZE,
962        };
963        let (start_x, start_y) = (grip.x + GRIP_SIZE / 2.0, grip.y + GRIP_SIZE / 2.0);
964        frame.on_event(&press(start_x as f64, start_y as f64));
965        frame.on_event(&Event::PointerMoved {
966            x: (start_x + 60.0) as f64,
967            y: (start_y + 40.0) as f64,
968            source: PointerSource::Mouse,
969        });
970
971        let asked = asked.borrow();
972        assert_eq!(
973            asked.first().copied(),
974            Some((400.0, 300.0)),
975            "grabbing the grip without moving must ask for the size the window already is"
976        );
977        assert_eq!(
978            asked.last().copied(),
979            Some((460.0, 340.0)),
980            "the window grows by what the pointer travelled, not to where the pointer is"
981        );
982    }
983
984    /// The grip has to be *on screen*, and a frame around an application is where it stops being.
985    ///
986    /// A settings-sized float hands `surface_frame` a body sized to fill the window — its page area is a scroll
987    /// leaf with a definite height, computed from the surface height less the chrome that existed before there
988    /// was a grip. A body that will not shrink below its content pushes the grip row past the bottom edge, and
989    /// the affordance builds, lays out, and is never visible. Which is exactly what happened.
990    #[test]
991    fn the_grip_stays_inside_a_window_whose_body_wants_all_of_it() {
992        reset_layout_runtime();
993
994        const SURFACE: (f32, f32) = (920.0, 680.0);
995        let style = SurfaceFrameStyle {
996            background: Color::TRANSPARENT,
997            title_bar: Color::TRANSPARENT,
998            title_text: Color::TRANSPARENT,
999            close: Color::TRANSPARENT,
1000            radius: 0.0,
1001            font_size: 12.0,
1002        };
1003        // Taller than the surface, the way an application body is once its own chrome is added on top.
1004        let hungry = box_item(
1005            StyledContainer::new(
1006                LayoutStyle::new().width(600.0).height(SURFACE.1),
1007                |_r| RectStyle::default(),
1008                vec![],
1009            )
1010            .unwrap(),
1011        );
1012        let asked: Rc<std::cell::RefCell<Vec<(f32, f32)>>> =
1013            Rc::new(std::cell::RefCell::new(Vec::new()));
1014        let sink = Rc::clone(&asked);
1015        let mut frame = surface_frame(
1016            "Settings",
1017            style,
1018            Rc::new(|| {}),
1019            hungry,
1020            Some(Rc::new(move |w, h| sink.borrow_mut().push((w, h)))),
1021        )
1022        .unwrap();
1023        compute_layout(
1024            frame.layout_node(),
1025            AvailableSpace::Definite(SURFACE.0),
1026            AvailableSpace::Definite(SURFACE.1),
1027        )
1028        .unwrap();
1029
1030        // Pressing the bottom-right corner and dragging is the property the user actually has: a grip laid out past the bottom edge receives nothing, so nothing resizes.
1031        let (x, y) = (
1032            SURFACE.0 - 4.0 - GRIP_SIZE / 2.0,
1033            SURFACE.1 - 4.0 - GRIP_SIZE / 2.0,
1034        );
1035        frame.on_event(&press(x as f64, y as f64));
1036        frame.on_event(&Event::PointerMoved {
1037            x: (x + 40.0) as f64,
1038            y: (y + 30.0) as f64,
1039            source: PointerSource::Mouse,
1040        });
1041
1042        let asked = asked.borrow();
1043        assert!(
1044            !asked.is_empty(),
1045            "nothing at the window's bottom-right corner answered a drag — a body that refuses to shrink \
1046             pushes the grip row off the surface, where it lays out perfectly and is never seen"
1047        );
1048        assert_eq!(
1049            asked.last().copied(),
1050            Some((SURFACE.0 + 40.0, SURFACE.1 + 30.0)),
1051            "and once it is on screen it still resizes by what the pointer travelled"
1052        );
1053    }
1054
1055    #[test]
1056    fn a_frame_without_a_resize_callback_draws_no_grip() {
1057        reset_layout_runtime();
1058        let style = SurfaceFrameStyle {
1059            background: Color::TRANSPARENT,
1060            title_bar: Color::TRANSPARENT,
1061            title_text: Color::TRANSPARENT,
1062            close: Color::TRANSPARENT,
1063            radius: 0.0,
1064            font_size: 12.0,
1065        };
1066        // A grip a backend cannot act on must be absent rather than present and inert — an affordance that does nothing is worse than none.
1067        assert!(surface_frame("Clock", style, Rc::new(|| {}), panel(), None).is_ok());
1068    }
1069
1070    #[test]
1071    fn enter_transform_fade_is_opacity_only() {
1072        assert_eq!(enter_transform(EnterMotion::Fade, 0.0), (IDENTITY, 0.0));
1073        assert_eq!(enter_transform(EnterMotion::Fade, 0.5), (IDENTITY, 0.5));
1074        assert_eq!(enter_transform(EnterMotion::Fade, 1.0), (IDENTITY, 1.0));
1075    }
1076
1077    #[test]
1078    fn enter_transform_slide_offsets_from_edge_then_settles() {
1079        let (m, o) = enter_transform(EnterMotion::Slide(SurfaceAnchor::Top), 0.0);
1080        assert_eq!(o, 0.0);
1081        assert_eq!(m[5], -SLIDE_DISTANCE, "top slides down from above");
1082        assert_eq!(
1083            enter_transform(EnterMotion::Slide(SurfaceAnchor::Top), 1.0),
1084            (IDENTITY, 1.0)
1085        );
1086        assert_eq!(
1087            enter_transform(EnterMotion::Slide(SurfaceAnchor::Bottom), 0.0).0[5],
1088            SLIDE_DISTANCE
1089        );
1090        assert_eq!(
1091            enter_transform(EnterMotion::Slide(SurfaceAnchor::Left), 0.0).0[4],
1092            -SLIDE_DISTANCE
1093        );
1094        assert_eq!(
1095            enter_transform(EnterMotion::Slide(SurfaceAnchor::Right), 0.0).0[4],
1096            SLIDE_DISTANCE
1097        );
1098    }
1099}