//! The frame a mount puts around a screen. //! //! Three members now, and each answers a different question: //! //! ```text //! Screen { .. } what the screen is //! Shell { .. } what only the host knows //! Frame { verbs, status } the frame a mount puts around a screen //! ``` //! //! A screen can be put up in more than one place, and the places differ in //! what surrounds it rather than in what it is. goingson's compose is the live //! instance: the same fields, the same behaviours and the same //! `buildFieldsHtml` in both its windows, wrapped once in a modal and once in //! a window, and everything that diverges between them is the wrapping. //! //! # Why this is not [`Chrome`](crate::Chrome) //! //! `Chrome` is what the *app* offers from every screen: a palette, a global //! key, the help overlay. It outlives every screen and is held beside the //! router. A frame outlives no more than the mount that supplied it — open //! compose in a modal and in a window and there are two frames, one screen //! description, and one app chrome. Same shape, three lifetimes. //! //! # Why it is not on [`Screen`](crate::Screen) //! //! Because an unframed screen would then carry fields that mean nothing in //! half their uses, and because the mount is the party that knows. The screen //! cannot say whether the verb beside it reads "Cancel" or "Discard": that //! depends on where it was put up, which is a fact the screen does not have and //! should not be given. //! //! # What was measured //! //! Five divergences between goingson's two compose paths, and every one of them //! is placement: //! //! ```text //! divergence modal window //! ------------------ --------------------------- ------------------------------ //! verb placement footer action row, in-form toolbar, above the form //! the cancel verb "Cancel" "Discard" + overlay confirm //! feedback sink showToast setStatus() into a status bar //! attachments bar inside the template outside the form //! reply indicator inside the form in the toolbar //! ``` //! //! Note what the vocabulary does *not* take from that table: where the verbs //! sit. Two mounts supplying the same verbs should draw them in the same place, //! and a renderer that put a modal's row in the footer and a window's in a //! toolbar would be describing goingson's accident rather than answering it. //! The cancel-verb row is two different [`Act`]s, which two mounts supply //! because they mean two different things. use crate::layout; use crate::screen::{Act, Node}; /// What a mount puts around the screen it is showing. /// /// Supplied where a screen goes up rather than arriving with one, which is the /// whole of the ruling: a renderer holds this beside the description the way it /// already holds [`Chrome`](crate::Chrome), and it survives every answer that /// replaces the screen inside it. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Frame { /// What this mount offers over the screen inside it. /// /// Send, Attach, Discard. Ordinary [`Act`]s, so a verb carries its tone, /// its confirmation and its key with no second vocabulary: goingson's /// window says Discard with an overlay confirm and its modal says Cancel /// with none, and that difference is two `Act`s rather than two code paths. /// /// Empty is the frame that offers nothing, which is a frame that exists /// only for its [`status`](Self::status). pub verbs: Vec, /// Whether this mount has a place to say what happened. /// /// A window with a status bar says so; a modal that raises a toast does /// not, and its messages stack the way every other screen's do. So a /// [`Message`](crate::Message) marked [`Banner`](crate::layout::Notice::Banner) /// has somewhere in the frame to rest, and one marked /// [`Toast`](crate::layout::Notice::Toast) floats regardless. /// /// # A place, not a channel, and that is deliberate /// /// The ruling flagged this as the part that is not settled: a status sink /// looks like something a handler writes to over time, and the vocabulary /// has no channel shape. [`Screen::notices`](crate::Screen::notices) is a /// `Vec` — data on an answer — and inventing a second mechanism here /// would put two ways of saying one thing into the vocabulary. /// /// So this says only that the frame *has* a sink. What lands in it keeps /// arriving the way every notice already arrives, and the channel question /// is still open and still worth a task of its own if a measured screen /// turns out to need more than a place. pub status: bool, } impl Frame { /// No frame. What a mount that declares none has. #[must_use] pub fn new() -> Self { Self::default() } /// Offer this verb over the screen, chaining. #[must_use] pub fn offering(mut self, verb: Act) -> Self { self.verbs.push(verb); self } /// This mount has a place to say what happened. /// /// See [`status`](Self::status). Says nothing about what lands there. #[must_use] pub const fn reporting(mut self) -> Self { self.status = true; self } /// Whether this notice rests in the frame rather than floating over the /// screen. /// /// The rule stated once, so the three renderers cannot each decide it. A /// [`Banner`](layout::Notice::Banner) is persistent and in flow, and a /// mount that says it reports is a mount with a place for one to rest; a /// [`Toast`](layout::Notice::Toast) is transient and floats, which is what /// goingson's modal raises and is unaffected by any of this. /// /// This is what makes the status line usable without a channel: /// [`Screen::notices`](crate::Screen::notices) already carries the /// messages, and a reporting frame changes where one of the two kinds /// lands rather than adding a second way to say it. A screen shown in a /// frame that does not report draws exactly what it always drew. #[must_use] pub fn holds(&self, notice: &Node) -> bool { self.status && matches!( notice, Node::Notice { kind: layout::Notice::Banner, .. } ) } /// Whether this frame draws anything at all. /// /// Asked once here rather than in each renderer, which would otherwise each /// decide whether an empty frame is a row of nothing or no row. #[must_use] pub fn bare(&self) -> bool { self.verbs.is_empty() && !self.status } } #[cfg(test)] mod tests { use super::*; use crate::screen::Action; #[test] fn a_mount_that_says_nothing_frames_nothing() { // The default has to be the old behaviour, or every host that puts a // screen up changes what it draws when this arrives. assert!(Frame::new().bare()); } #[test] fn a_frame_with_only_a_status_line_is_not_bare() { // goingson's compose window before its verbs are described: it still // has the bar, and a renderer drawing no frame would lose it. assert!(!Frame::new().reporting().bare()); } #[test] fn a_banner_rests_in_a_reporting_frame_and_a_toast_never_does() { // The rule stated once, so three renderers cannot each decide it. A // toast is transient and floats, which is what goingson's modal raises // and is what an unframed screen has always done with both. let banner = Node::banner(crate::layout::Tone::Danger, "Not sent"); let toast = Node::Notice { kind: crate::layout::Notice::Toast, tone: crate::layout::Tone::Info, text: "Saved".into(), act: None, }; let reporting = Frame::new().reporting(); assert!(reporting.holds(&banner)); assert!(!reporting.holds(&toast)); // A mount with no place for one changes nothing about either. let quiet = Frame::new(); assert!(!quiet.holds(&banner)); assert!(!quiet.holds(&toast)); } #[test] fn two_mounts_differ_by_the_verbs_they_supply() { // The cancel-verb divergence, which is the one row of the measured // table the vocabulary keeps. The window confirms and the modal does // not, and that is two `Act`s rather than two code paths. let modal = Frame::new().offering(Act::new("Cancel", Action::post("/compose/close"))); let window = Frame::new() .offering( Act::new("Discard", Action::post("/compose/discard")) .confirm("Discard this draft?"), ) .reporting(); assert_eq!(modal.verbs[0].label, "Cancel"); assert!(modal.verbs[0].confirm.is_none()); assert_eq!(window.verbs[0].label, "Discard"); assert!(window.verbs[0].confirm.is_some()); assert!(!modal.status); assert!(window.status); } }