Skip to main content

snora_core/
overlay.rs

1//! Dialogs and bottom sheets — the modal overlay surfaces.
2//!
3//! Both overlay types are **pure content carriers**. They do not own close
4//! handlers; outside-click dismissal is installed once at the
5//! [`crate::AppLayout`] level via [`crate::AppLayout::on_close_modals`], so
6//! there is exactly one place to wire the close message regardless of which
7//! modal is showing.
8//!
9//! # Sheet height
10//!
11//! [`BottomSheet`] carries a [`SheetHeight`] enum rather than a raw number.
12//! The engine resolves the enum to a concrete height at render time. This
13//! keeps the vocabulary in snora-core (iced-free) and confines physical
14//! pixel decisions to the engine.
15
16use std::marker::PhantomData;
17
18/// A modal dialog.
19///
20/// The engine centers the content on the screen and installs a dim backdrop
21/// that captures outside clicks (configured via the parent
22/// [`crate::AppLayout::on_close_modals`]).
23///
24/// The `Message` type parameter is preserved for future extension (e.g.
25/// per-dialog animations or lifecycle hooks) without breaking API shape.
26pub struct Dialog<Node, Message> {
27    pub content: Node,
28    _marker: PhantomData<Message>,
29}
30
31impl<Node, Message> Dialog<Node, Message> {
32    /// Wrap a content node as a dialog.
33    pub fn new(content: Node) -> Self {
34        Self {
35            content,
36            _marker: PhantomData,
37        }
38    }
39}
40
41/// The vertical size a bottom sheet should occupy, relative to the window.
42///
43/// Use the named variants for canonical proportions; use [`SheetHeight::Ratio`]
44/// for arbitrary fractions (clamped to `0.0..=1.0`); use
45/// [`SheetHeight::Pixels`] for a fixed pixel height independent of window
46/// size (discouraged for responsive apps).
47#[derive(Debug, Clone, Copy, PartialEq)]
48pub enum SheetHeight {
49    OneThird,
50    Half,
51    TwoThirds,
52    /// Arbitrary fraction of window height. Values outside `0.0..=1.0` are
53    /// clamped by the engine.
54    Ratio(f32),
55    /// Fixed pixel height. Only use when the content has a natural size that
56    /// does not scale with the window.
57    Pixels(f32),
58}
59
60impl SheetHeight {
61    /// The default height — one-third of the window. Matches the canonical
62    /// "drawer from the bottom" feel without dominating the screen.
63    pub const DEFAULT: SheetHeight = SheetHeight::OneThird;
64
65    /// Resolve to a fraction of the window, if this variant expresses one.
66    /// Returns `None` for [`SheetHeight::Pixels`].
67    #[must_use]
68    pub fn as_ratio(self) -> Option<f32> {
69        match self {
70            SheetHeight::OneThird => Some(1.0 / 3.0),
71            SheetHeight::Half => Some(0.5),
72            SheetHeight::TwoThirds => Some(2.0 / 3.0),
73            SheetHeight::Ratio(r) => Some(r.clamp(0.0, 1.0)),
74            SheetHeight::Pixels(_) => None,
75        }
76    }
77
78    /// Resolve to a pixel value, if this variant expresses one.
79    /// Returns `None` for the ratio-based variants.
80    #[must_use]
81    pub fn as_pixels(self) -> Option<f32> {
82        match self {
83            SheetHeight::Pixels(p) => Some(p),
84            _ => None,
85        }
86    }
87}
88
89/// A sheet that slides up from the bottom of the window.
90///
91/// Like [`Dialog`], a sheet is content only. The dim backdrop and its
92/// outside-click-to-close behavior are owned by the parent [`crate::AppLayout`].
93pub struct BottomSheet<Node, Message> {
94    pub content: Node,
95    pub height: SheetHeight,
96    _marker: PhantomData<Message>,
97}
98
99impl<Node, Message> BottomSheet<Node, Message> {
100    /// Build a sheet with [`SheetHeight::DEFAULT`].
101    pub fn new(content: Node) -> Self {
102        Self {
103            content,
104            height: SheetHeight::DEFAULT,
105            _marker: PhantomData,
106        }
107    }
108
109    /// Override the height.
110    #[must_use]
111    pub fn with_height(mut self, height: SheetHeight) -> Self {
112        self.height = height;
113        self
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn ratio_resolves_correctly() {
123        assert_eq!(SheetHeight::OneThird.as_ratio(), Some(1.0 / 3.0));
124        assert_eq!(SheetHeight::Half.as_ratio(), Some(0.5));
125        assert_eq!(SheetHeight::TwoThirds.as_ratio(), Some(2.0 / 3.0));
126        assert_eq!(SheetHeight::Ratio(0.25).as_ratio(), Some(0.25));
127        assert_eq!(SheetHeight::Pixels(240.0).as_ratio(), None);
128    }
129
130    #[test]
131    fn ratio_is_clamped() {
132        assert_eq!(SheetHeight::Ratio(1.5).as_ratio(), Some(1.0));
133        assert_eq!(SheetHeight::Ratio(-0.1).as_ratio(), Some(0.0));
134    }
135}