Skip to main content

snora_core/
layout.rs

1//! The application skeleton — [`AppLayout`].
2//!
3//! `AppLayout` is the **only** shape an engine consumes. It is a plain
4//! data structure with `pub` fields plus a builder-style API. Every slot
5//! is a `Node` of the same generic type — when rendered with snora, that
6//! binds to `iced::Element<'a, Message>`, so all four layout slots accept
7//! any iced element regardless of how the application organized its view
8//! code.
9//!
10//! # Canonical construction
11//!
12//! `AppLayout::new(body)` is the minimum — just a body element. Every
13//! other slot has a sensible default and is set via a chainable method:
14//!
15//! ```rust,no_run
16//! use snora_core::{AppLayout, LayoutDirection};
17//!
18//! #[derive(Clone)]
19//! enum Message { CloseMenus, CloseModals }
20//!
21//! fn my_body() -> () {}
22//! fn my_header() -> () {}
23//! fn my_sidebar() -> () {}
24//! fn my_footer() -> () {}
25//!
26//! let layout = AppLayout::new(my_body())
27//!     .header(my_header())
28//!     .side_bar(my_sidebar())
29//!     .footer(my_footer())
30//!     .direction(LayoutDirection::Rtl)
31//!     .on_close_menus(Message::CloseMenus)
32//!     .on_close_modals(Message::CloseModals);
33//! ```
34//!
35//! The struct is `#[non_exhaustive]` so future top-level surfaces can be
36//! added without breaking downstream callers. Fields remain `pub` for
37//! readability; builder methods are the stable construction contract.
38//!
39//! # Why no `PageContract`?
40//!
41//! Earlier drafts of snora required layout slots to implement a
42//! `PageContract` trait that declared `view()`, `dialog()`, `toasts()`,
43//! and close hooks. The engine never actually consumed the non-`view`
44//! methods, so users were forced to plumb them manually anyway, and the
45//! trait's associated-type machinery forced all four slots to share a
46//! single type — a painful tax that produced the `Section` enum pattern.
47//!
48//! v0.4 drops the trait. Every slot is a `Node` value of the same generic
49//! type — in practice, `iced::Element<'a, Message>`. Because any function
50//! can return an `Element`, each slot can be built by a different piece of
51//! application code without any wrapping trait or enum, and all overlay /
52//! close state lives as plain fields here.
53
54use crate::{
55    direction::LayoutDirection,
56    overlay::{Dialog, Sheet},
57    toast::{Toast, ToastPosition},
58};
59
60/// The complete declarative description of what should be on screen.
61///
62/// Type parameters:
63/// * `Node` — the element type your engine consumes. With the `snora`
64///   engine, this is `iced::Element<'a, Message>`.
65/// * `Message` — your application's top-level message type.
66///
67/// # Canonical construction
68///
69/// Use [`AppLayout::new`] plus chainable builder methods. This is the
70/// stable, long-term construction path:
71///
72/// ```rust,no_run
73/// use snora_core::AppLayout;
74///
75/// #[derive(Clone)]
76/// enum Message { CloseMenus, CloseModals }
77///
78/// let body: () = ();
79/// let header: () = ();
80/// let sidebar: () = ();
81///
82/// let layout = AppLayout::new(body)
83///     .header(header)
84///     .side_bar(sidebar)
85///     .on_close_menus(Message::CloseMenus)
86///     .on_close_modals(Message::CloseModals);
87/// ```
88///
89/// Fields are `pub` for readability and in-crate access. Direct struct
90/// literal construction from *outside* `snora-core` is not supported
91/// (the struct is `#[non_exhaustive]`) so that future top-level surfaces
92/// can be added as additive changes. Any new field ships with a
93/// corresponding `#[must_use]` builder method.
94#[non_exhaustive]
95pub struct AppLayout<Node, Message>
96where
97    Message: Clone,
98{
99    // -----------------------------------------------------------------
100    // Primary skeleton slots.
101    // -----------------------------------------------------------------
102    /// The main content area. Required.
103    pub body: Node,
104    /// Top header bar (typically built with [`crate::menu::Menu`] entries).
105    pub header: Option<Node>,
106    /// Vertical navigation rail. Renders on the start edge by default and
107    /// flips with [`Self::direction`].
108    pub side_bar: Option<Node>,
109    /// Status bar at the bottom of the window.
110    pub footer: Option<Node>,
111
112    // -----------------------------------------------------------------
113    // Light-weight overlays (menus).
114    //
115    // These render above the skeleton but below the modal dim layer.
116    // Outside-click dismissal is wired via `on_close_menus`.
117    // -----------------------------------------------------------------
118    /// Optional header-attached dropdown (e.g. File menu's item list).
119    /// When `Some`, the engine installs a transparent backdrop that
120    /// dispatches [`Self::on_close_menus`] on any outside click.
121    pub header_menu: Option<Node>,
122    /// Optional floating context menu (right-click menu). Same backdrop
123    /// behavior as `header_menu`.
124    pub context_menu: Option<Node>,
125
126    // -----------------------------------------------------------------
127    // Modal overlays.
128    //
129    // These render above everything except toasts. The engine paints a
130    // dimmed backdrop behind them (when any modal is present) and wires
131    // outside-click to `on_close_modals`.
132    // -----------------------------------------------------------------
133    /// Centered modal content. The engine centers `Dialog`'s content and
134    /// paints the dim backdrop around it; by default no card chrome is
135    /// drawn — the application supplies its own, or opts into a
136    /// token-styled card via `snora::design::render` (see the `snora`
137    /// crate's overlays guide).
138    pub dialog: Option<Dialog<Node, Message>>,
139    /// A modal panel anchored to one of the four window edges. The
140    /// specific edge is configured on the [`Sheet`] itself.
141    pub sheet: Option<Sheet<Node, Message>>,
142
143    // -----------------------------------------------------------------
144    // Toasts.
145    //
146    // Always rendered at the top of the z-stack so they are visible even
147    // when a modal is open. The anchor corner is controlled by
148    // `toast_position`; horizontal mirroring under RTL is automatic
149    // because positions are expressed in logical (Start / End) terms.
150    // -----------------------------------------------------------------
151    /// The toast queue, owned by the application. snora does not mutate
152    /// this slice — see `snora::toast::sweep_expired` for in-place
153    /// expiration handling.
154    pub toasts: Vec<Toast<Message>>,
155
156    /// Anchor corner of the toast stack. Defaults to
157    /// [`ToastPosition::TopEnd`] (top-right under LTR, top-left under RTL).
158    pub toast_position: ToastPosition,
159
160    // -----------------------------------------------------------------
161    // Global configuration.
162    // -----------------------------------------------------------------
163    /// Reading direction. Drives sidebar side, header start/end ordering,
164    /// and toast anchor mirroring (when the position is `*Start` or `*End`).
165    pub direction: LayoutDirection,
166
167    // -----------------------------------------------------------------
168    // Close sinks.
169    //
170    // Single source of truth for outside-click dismissal. Individual
171    // overlay values do *not* carry their own close messages — the
172    // engine dispatches through these two channels.
173    // -----------------------------------------------------------------
174    /// Dispatched when the user clicks outside an open menu (header or
175    /// context). If `None`, menus still render but the click-outside-to-
176    /// close backdrop is not installed — the application must then
177    /// provide explicit close buttons inside its menu content.
178    pub on_close_menus: Option<Message>,
179
180    /// Dispatched when the user clicks the dim backdrop of a dialog or
181    /// bottom sheet. Semantics mirror [`Self::on_close_menus`].
182    pub on_close_modals: Option<Message>,
183}
184
185impl<Node, Message> AppLayout<Node, Message>
186where
187    Message: Clone,
188{
189    /// Start a layout with only a body. All other slots default to their
190    /// empty / `None` states.
191    pub fn new(body: Node) -> Self {
192        Self {
193            body,
194            header: None,
195            side_bar: None,
196            footer: None,
197            header_menu: None,
198            context_menu: None,
199            dialog: None,
200            sheet: None,
201            toasts: Vec::new(),
202            toast_position: ToastPosition::default(),
203            direction: LayoutDirection::default(),
204            on_close_menus: None,
205            on_close_modals: None,
206        }
207    }
208
209    // ---------------------------------------------------------------
210    // Skeleton slot setters.
211    // ---------------------------------------------------------------
212    /// Set the header element.
213    #[must_use]
214    pub fn header(mut self, header: Node) -> Self {
215        self.header = Some(header);
216        self
217    }
218
219    /// Set the sidebar element. Renders on the start edge by default.
220    #[must_use]
221    pub fn side_bar(mut self, side_bar: Node) -> Self {
222        self.side_bar = Some(side_bar);
223        self
224    }
225
226    /// Set the footer element.
227    #[must_use]
228    pub fn footer(mut self, footer: Node) -> Self {
229        self.footer = Some(footer);
230        self
231    }
232
233    // ---------------------------------------------------------------
234    // Overlay setters.
235    // ---------------------------------------------------------------
236    /// Set the header dropdown menu. Setting any value (typically an
237    /// empty `Space`) opts the application into the click-outside
238    /// backdrop; the actual dropdown items are drawn inline by the
239    /// header widget.
240    #[must_use]
241    pub fn header_menu(mut self, menu: Node) -> Self {
242        self.header_menu = Some(menu);
243        self
244    }
245
246    /// Set the floating context menu. Pass a positioned element.
247    #[must_use]
248    pub fn context_menu(mut self, menu: Node) -> Self {
249        self.context_menu = Some(menu);
250        self
251    }
252
253    /// Show a modal dialog.
254    #[must_use]
255    pub fn dialog(mut self, dialog: Dialog<Node, Message>) -> Self {
256        self.dialog = Some(dialog);
257        self
258    }
259
260    /// Show a modal sheet anchored to one of the window edges.
261    /// Configure the anchor with `Sheet::at(...)` on the value passed in.
262    #[must_use]
263    pub fn sheet(mut self, sheet: Sheet<Node, Message>) -> Self {
264        self.sheet = Some(sheet);
265        self
266    }
267
268    /// Replace the toast queue. Each frame the application typically passes
269    /// `state.toasts.clone()` here; snora does not mutate the slice. See
270    /// `snora::toast::subscription` and `snora::toast::sweep_expired` for
271    /// framework-managed lifetime handling.
272    #[must_use]
273    pub fn toasts(mut self, toasts: Vec<Toast<Message>>) -> Self {
274        self.toasts = toasts;
275        self
276    }
277
278    /// Override the toast anchor corner. Defaults to
279    /// [`ToastPosition::TopEnd`].
280    #[must_use]
281    pub fn toast_position(mut self, position: ToastPosition) -> Self {
282        self.toast_position = position;
283        self
284    }
285
286    // ---------------------------------------------------------------
287    // Configuration setters.
288    // ---------------------------------------------------------------
289    /// Override the reading direction.
290    #[must_use]
291    pub fn direction(mut self, direction: LayoutDirection) -> Self {
292        self.direction = direction;
293        self
294    }
295
296    /// Wire the click-outside-to-close handler for header / context menus.
297    #[must_use]
298    pub fn on_close_menus(mut self, msg: Message) -> Self {
299        self.on_close_menus = Some(msg);
300        self
301    }
302
303    /// Wire the click-outside-to-close handler for dialog / bottom sheet.
304    #[must_use]
305    pub fn on_close_modals(mut self, msg: Message) -> Self {
306        self.on_close_modals = Some(msg);
307        self
308    }
309}