Skip to main content

snora_core/
overlay.rs

1//! Dialogs and edge-anchored 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//! # Sheets
10//!
11//! A [`Sheet`] is a panel that slides in from one of the four window
12//! edges ([`SheetEdge`]) and occupies a configurable size ([`SheetSize`])
13//! along the perpendicular axis. The engine resolves the enums to concrete
14//! pixels at render time, so this module remains iced-free.
15//!
16//! [`SheetSize`] is interpreted *along the axis perpendicular to the
17//! anchor edge*:
18//!
19//! * For [`SheetEdge::Top`] / [`SheetEdge::Bottom`] the size is a height
20//!   (vertical).
21//! * For [`SheetEdge::Start`] / [`SheetEdge::End`] the size is a width
22//!   (horizontal).
23//!
24//! This is intentional: a single `SheetSize::Half` reads naturally as
25//! "half of the relevant axis" no matter which edge the sheet attaches to.
26
27use std::marker::PhantomData;
28
29/// A modal dialog.
30///
31/// The engine centers the content on the screen and installs a dim backdrop
32/// that captures outside clicks (configured via the parent
33/// [`crate::AppLayout::on_close_modals`]).
34///
35/// The `Message` type parameter is preserved for future extension (e.g.
36/// per-dialog animations or lifecycle hooks) without breaking API shape.
37pub struct Dialog<Node, Message> {
38    /// The dialog body content. The engine centers this in the window and
39    /// paints the dim backdrop around it.
40    pub content: Node,
41    _marker: PhantomData<Message>,
42}
43
44impl<Node, Message> Dialog<Node, Message> {
45    /// Wrap a content node as a dialog.
46    pub fn new(content: Node) -> Self {
47        Self {
48            content,
49            _marker: PhantomData,
50        }
51    }
52}
53
54/// Which window edge a sheet attaches to.
55///
56/// `Start` / `End` are logical, mirroring under
57/// [`crate::LayoutDirection::Rtl`] just like every other axis-aligned
58/// vocabulary in snora. `Top` / `Bottom` are direction-independent.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
60pub enum SheetEdge {
61    /// Slides up from the bottom of the window. The historical default;
62    /// matches the "drawer from below" idiom.
63    #[default]
64    Bottom,
65    /// Slides down from the top of the window.
66    Top,
67    /// Slides in from the logical start edge (LTR=left, RTL=right).
68    Start,
69    /// Slides in from the logical end edge (LTR=right, RTL=left).
70    End,
71}
72
73impl SheetEdge {
74    /// Whether this edge anchors along the **vertical** axis.
75    /// `true` for `Top` / `Bottom`; `false` for `Start` / `End`.
76    ///
77    /// # Example
78    ///
79    /// ```
80    /// use snora_core::SheetEdge;
81    ///
82    /// assert!(SheetEdge::Top.is_vertical());
83    /// assert!(SheetEdge::Bottom.is_vertical());
84    /// assert!(!SheetEdge::Start.is_vertical());
85    /// ```
86    #[must_use]
87    pub fn is_vertical(self) -> bool {
88        matches!(self, SheetEdge::Top | SheetEdge::Bottom)
89    }
90
91    /// Whether this edge anchors along the **horizontal** axis.
92    /// `true` for `Start` / `End`; `false` for `Top` / `Bottom`.
93    ///
94    /// Always the inverse of [`Self::is_vertical`].
95    #[must_use]
96    pub fn is_horizontal(self) -> bool {
97        !self.is_vertical()
98    }
99}
100
101/// The size a sheet should occupy along the axis perpendicular to its
102/// anchor edge.
103///
104/// * For a top- or bottom-anchored sheet, `SheetSize` is a height.
105/// * For a start- or end-anchored sheet, `SheetSize` is a width.
106///
107/// Use the named variants for canonical proportions; use [`SheetSize::Ratio`]
108/// for arbitrary fractions (clamped to `0.0..=1.0`); use [`SheetSize::Pixels`]
109/// for a fixed pixel size independent of window dimensions (discouraged for
110/// responsive apps).
111#[derive(Debug, Clone, Copy, PartialEq)]
112pub enum SheetSize {
113    /// 33 % of the window's relevant axis — the default "drawer" size.
114    OneThird,
115    /// 50 % of the window's relevant axis.
116    Half,
117    /// 67 % of the window's relevant axis.
118    TwoThirds,
119    /// Arbitrary fraction of the window's relevant axis. Values outside
120    /// `0.0..=1.0` are clamped by the engine.
121    Ratio(f32),
122    /// Fixed pixel size. Only use when the content has a natural size that
123    /// does not scale with the window.
124    Pixels(f32),
125}
126
127impl SheetSize {
128    /// The default size — one-third of the window.
129    pub const DEFAULT: SheetSize = SheetSize::OneThird;
130
131    /// Resolve to a fraction of the relevant axis, if this variant
132    /// expresses one. Returns `None` for [`SheetSize::Pixels`].
133    ///
134    /// # Example
135    ///
136    /// ```
137    /// use snora_core::SheetSize;
138    ///
139    /// assert_eq!(SheetSize::Half.as_ratio(), Some(0.5));
140    /// assert_eq!(SheetSize::Ratio(1.5).as_ratio(), Some(1.0)); // clamped
141    /// assert_eq!(SheetSize::Pixels(240.0).as_ratio(), None);
142    /// ```
143    #[must_use]
144    pub fn as_ratio(self) -> Option<f32> {
145        match self {
146            SheetSize::OneThird => Some(1.0 / 3.0),
147            SheetSize::Half => Some(0.5),
148            SheetSize::TwoThirds => Some(2.0 / 3.0),
149            SheetSize::Ratio(r) => Some(r.clamp(0.0, 1.0)),
150            SheetSize::Pixels(_) => None,
151        }
152    }
153
154    /// Resolve to a pixel value, if this variant expresses one.
155    /// Returns `None` for the ratio-based variants.
156    ///
157    /// # Example
158    ///
159    /// ```
160    /// use snora_core::SheetSize;
161    ///
162    /// assert_eq!(SheetSize::Pixels(280.0).as_pixels(), Some(280.0));
163    /// assert_eq!(SheetSize::Half.as_pixels(), None);
164    /// ```
165    #[must_use]
166    pub fn as_pixels(self) -> Option<f32> {
167        match self {
168            SheetSize::Pixels(p) => Some(p),
169            _ => None,
170        }
171    }
172}
173
174/// A panel that slides in from one of the window edges.
175///
176/// Like [`Dialog`], a sheet is content only. The dim backdrop and its
177/// outside-click-to-close behavior are owned by the parent [`crate::AppLayout`].
178///
179/// # Builder usage
180///
181/// ```
182/// use snora_core::{Sheet, SheetEdge, SheetSize};
183///
184/// // Use `()` for the content/message parameters when illustrating the
185/// // shape only. In application code these are `iced::Element<'_, M>` and
186/// // your `Message` type.
187/// let sheet: Sheet<(), ()> = Sheet::new(())
188///     .at(SheetEdge::Start)
189///     .with_size(SheetSize::Half);
190///
191/// assert_eq!(sheet.edge, SheetEdge::Start);
192/// assert_eq!(sheet.size, SheetSize::Half);
193/// ```
194pub struct Sheet<Node, Message> {
195    /// The sheet's body content. The engine wraps this in a styled surface
196    /// sized according to `size` and anchored to `edge`.
197    pub content: Node,
198    /// Where the sheet attaches. Defaults to [`SheetEdge::Bottom`].
199    pub edge: SheetEdge,
200    /// Size of the sheet along the axis perpendicular to `edge`.
201    /// Defaults to [`SheetSize::DEFAULT`].
202    pub size: SheetSize,
203    _marker: PhantomData<Message>,
204}
205
206impl<Node, Message> Sheet<Node, Message> {
207    /// Build a sheet with default edge ([`SheetEdge::Bottom`]) and size
208    /// ([`SheetSize::DEFAULT`]).
209    pub fn new(content: Node) -> Self {
210        Self {
211            content,
212            edge: SheetEdge::default(),
213            size: SheetSize::DEFAULT,
214            _marker: PhantomData,
215        }
216    }
217
218    /// Override the anchor edge.
219    #[must_use]
220    pub fn at(mut self, edge: SheetEdge) -> Self {
221        self.edge = edge;
222        self
223    }
224
225    /// Override the size.
226    #[must_use]
227    pub fn with_size(mut self, size: SheetSize) -> Self {
228        self.size = size;
229        self
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn ratio_resolves_correctly() {
239        assert_eq!(SheetSize::OneThird.as_ratio(), Some(1.0 / 3.0));
240        assert_eq!(SheetSize::Half.as_ratio(), Some(0.5));
241        assert_eq!(SheetSize::TwoThirds.as_ratio(), Some(2.0 / 3.0));
242        assert_eq!(SheetSize::Ratio(0.25).as_ratio(), Some(0.25));
243        assert_eq!(SheetSize::Pixels(240.0).as_ratio(), None);
244    }
245
246    #[test]
247    fn ratio_is_clamped() {
248        assert_eq!(SheetSize::Ratio(1.5).as_ratio(), Some(1.0));
249        assert_eq!(SheetSize::Ratio(-0.1).as_ratio(), Some(0.0));
250    }
251
252    #[test]
253    fn default_sheet_edge_is_bottom() {
254        assert_eq!(SheetEdge::default(), SheetEdge::Bottom);
255    }
256
257    #[test]
258    fn vertical_horizontal_partition() {
259        for edge in [
260            SheetEdge::Top,
261            SheetEdge::Bottom,
262            SheetEdge::Start,
263            SheetEdge::End,
264        ] {
265            assert_ne!(edge.is_vertical(), edge.is_horizontal());
266        }
267        assert!(SheetEdge::Top.is_vertical());
268        assert!(SheetEdge::Bottom.is_vertical());
269        assert!(SheetEdge::Start.is_horizontal());
270        assert!(SheetEdge::End.is_horizontal());
271    }
272
273    #[test]
274    fn sheet_builder_overrides() {
275        let s: Sheet<(), ()> = Sheet::new(())
276            .at(SheetEdge::Start)
277            .with_size(SheetSize::Half);
278        assert_eq!(s.edge, SheetEdge::Start);
279        assert_eq!(s.size, SheetSize::Half);
280    }
281}