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}