Skip to main content

max / quasi

9.0 KB · 221 lines History Blame Raw
1 //! The frame a mount puts around a screen.
2 //!
3 //! Three members now, and each answers a different question:
4 //!
5 //! ```text
6 //! Screen { .. } what the screen is
7 //! Shell { .. } what only the host knows
8 //! Frame { verbs, status } the frame a mount puts around a screen
9 //! ```
10 //!
11 //! A screen can be put up in more than one place, and the places differ in
12 //! what surrounds it rather than in what it is. goingson's compose is the live
13 //! instance: the same fields, the same behaviours and the same
14 //! `buildFieldsHtml` in both its windows, wrapped once in a modal and once in
15 //! a window, and everything that diverges between them is the wrapping.
16 //!
17 //! # Why this is not [`Chrome`](crate::Chrome)
18 //!
19 //! `Chrome` is what the *app* offers from every screen: a palette, a global
20 //! key, the help overlay. It outlives every screen and is held beside the
21 //! router. A frame outlives no more than the mount that supplied it — open
22 //! compose in a modal and in a window and there are two frames, one screen
23 //! description, and one app chrome. Same shape, three lifetimes.
24 //!
25 //! # Why it is not on [`Screen`](crate::Screen)
26 //!
27 //! Because an unframed screen would then carry fields that mean nothing in
28 //! half their uses, and because the mount is the party that knows. The screen
29 //! cannot say whether the verb beside it reads "Cancel" or "Discard": that
30 //! depends on where it was put up, which is a fact the screen does not have and
31 //! should not be given.
32 //!
33 //! # What was measured
34 //!
35 //! Five divergences between goingson's two compose paths, and every one of them
36 //! is placement:
37 //!
38 //! ```text
39 //! divergence modal window
40 //! ------------------ --------------------------- ------------------------------
41 //! verb placement footer action row, in-form toolbar, above the form
42 //! the cancel verb "Cancel" "Discard" + overlay confirm
43 //! feedback sink showToast setStatus() into a status bar
44 //! attachments bar inside the template outside the form
45 //! reply indicator inside the form in the toolbar
46 //! ```
47 //!
48 //! Note what the vocabulary does *not* take from that table: where the verbs
49 //! sit. Two mounts supplying the same verbs should draw them in the same place,
50 //! and a renderer that put a modal's row in the footer and a window's in a
51 //! toolbar would be describing goingson's accident rather than answering it.
52 //! The cancel-verb row is two different [`Act`]s, which two mounts supply
53 //! because they mean two different things.
54
55 use crate::layout;
56 use crate::screen::{Act, Node};
57
58 /// What a mount puts around the screen it is showing.
59 ///
60 /// Supplied where a screen goes up rather than arriving with one, which is the
61 /// whole of the ruling: a renderer holds this beside the description the way it
62 /// already holds [`Chrome`](crate::Chrome), and it survives every answer that
63 /// replaces the screen inside it.
64 #[derive(Debug, Clone, Default, PartialEq, Eq)]
65 pub struct Frame {
66 /// What this mount offers over the screen inside it.
67 ///
68 /// Send, Attach, Discard. Ordinary [`Act`]s, so a verb carries its tone,
69 /// its confirmation and its key with no second vocabulary: goingson's
70 /// window says Discard with an overlay confirm and its modal says Cancel
71 /// with none, and that difference is two `Act`s rather than two code paths.
72 ///
73 /// Empty is the frame that offers nothing, which is a frame that exists
74 /// only for its [`status`](Self::status).
75 pub verbs: Vec<Act>,
76 /// Whether this mount has a place to say what happened.
77 ///
78 /// A window with a status bar says so; a modal that raises a toast does
79 /// not, and its messages stack the way every other screen's do. So a
80 /// [`Message`](crate::Message) marked [`Banner`](crate::layout::Notice::Banner)
81 /// has somewhere in the frame to rest, and one marked
82 /// [`Toast`](crate::layout::Notice::Toast) floats regardless.
83 ///
84 /// # A place, not a channel, and that is deliberate
85 ///
86 /// The ruling flagged this as the part that is not settled: a status sink
87 /// looks like something a handler writes to over time, and the vocabulary
88 /// has no channel shape. [`Screen::notices`](crate::Screen::notices) is a
89 /// `Vec<Node>` — data on an answer — and inventing a second mechanism here
90 /// would put two ways of saying one thing into the vocabulary.
91 ///
92 /// So this says only that the frame *has* a sink. What lands in it keeps
93 /// arriving the way every notice already arrives, and the channel question
94 /// is still open and still worth a task of its own if a measured screen
95 /// turns out to need more than a place.
96 pub status: bool,
97 }
98
99 impl Frame {
100 /// No frame. What a mount that declares none has.
101 #[must_use]
102 pub fn new() -> Self {
103 Self::default()
104 }
105
106 /// Offer this verb over the screen, chaining.
107 #[must_use]
108 pub fn offering(mut self, verb: Act) -> Self {
109 self.verbs.push(verb);
110 self
111 }
112
113 /// This mount has a place to say what happened.
114 ///
115 /// See [`status`](Self::status). Says nothing about what lands there.
116 #[must_use]
117 pub const fn reporting(mut self) -> Self {
118 self.status = true;
119 self
120 }
121
122 /// Whether this notice rests in the frame rather than floating over the
123 /// screen.
124 ///
125 /// The rule stated once, so the three renderers cannot each decide it. A
126 /// [`Banner`](layout::Notice::Banner) is persistent and in flow, and a
127 /// mount that says it reports is a mount with a place for one to rest; a
128 /// [`Toast`](layout::Notice::Toast) is transient and floats, which is what
129 /// goingson's modal raises and is unaffected by any of this.
130 ///
131 /// This is what makes the status line usable without a channel:
132 /// [`Screen::notices`](crate::Screen::notices) already carries the
133 /// messages, and a reporting frame changes where one of the two kinds
134 /// lands rather than adding a second way to say it. A screen shown in a
135 /// frame that does not report draws exactly what it always drew.
136 #[must_use]
137 pub fn holds(&self, notice: &Node) -> bool {
138 self.status
139 && matches!(
140 notice,
141 Node::Notice {
142 kind: layout::Notice::Banner,
143 ..
144 }
145 )
146 }
147
148 /// Whether this frame draws anything at all.
149 ///
150 /// Asked once here rather than in each renderer, which would otherwise each
151 /// decide whether an empty frame is a row of nothing or no row.
152 #[must_use]
153 pub fn bare(&self) -> bool {
154 self.verbs.is_empty() && !self.status
155 }
156 }
157
158 #[cfg(test)]
159 mod tests {
160 use super::*;
161 use crate::screen::Action;
162
163 #[test]
164 fn a_mount_that_says_nothing_frames_nothing() {
165 // The default has to be the old behaviour, or every host that puts a
166 // screen up changes what it draws when this arrives.
167 assert!(Frame::new().bare());
168 }
169
170 #[test]
171 fn a_frame_with_only_a_status_line_is_not_bare() {
172 // goingson's compose window before its verbs are described: it still
173 // has the bar, and a renderer drawing no frame would lose it.
174 assert!(!Frame::new().reporting().bare());
175 }
176
177 #[test]
178 fn a_banner_rests_in_a_reporting_frame_and_a_toast_never_does() {
179 // The rule stated once, so three renderers cannot each decide it. A
180 // toast is transient and floats, which is what goingson's modal raises
181 // and is what an unframed screen has always done with both.
182 let banner = Node::banner(crate::layout::Tone::Danger, "Not sent");
183 let toast = Node::Notice {
184 kind: crate::layout::Notice::Toast,
185 tone: crate::layout::Tone::Info,
186 text: "Saved".into(),
187 act: None,
188 };
189
190 let reporting = Frame::new().reporting();
191 assert!(reporting.holds(&banner));
192 assert!(!reporting.holds(&toast));
193
194 // A mount with no place for one changes nothing about either.
195 let quiet = Frame::new();
196 assert!(!quiet.holds(&banner));
197 assert!(!quiet.holds(&toast));
198 }
199
200 #[test]
201 fn two_mounts_differ_by_the_verbs_they_supply() {
202 // The cancel-verb divergence, which is the one row of the measured
203 // table the vocabulary keeps. The window confirms and the modal does
204 // not, and that is two `Act`s rather than two code paths.
205 let modal = Frame::new().offering(Act::new("Cancel", Action::post("/compose/close")));
206 let window = Frame::new()
207 .offering(
208 Act::new("Discard", Action::post("/compose/discard"))
209 .confirm("Discard this draft?"),
210 )
211 .reporting();
212
213 assert_eq!(modal.verbs[0].label, "Cancel");
214 assert!(modal.verbs[0].confirm.is_none());
215 assert_eq!(window.verbs[0].label, "Discard");
216 assert!(window.verbs[0].confirm.is_some());
217 assert!(!modal.status);
218 assert!(window.status);
219 }
220 }
221