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}