Skip to main content

max / quasi

14.7 KB · 377 lines History Blame Raw
1 //! An ordered set of frames, said once.
2 //!
3 //! The widget tier's first consumer, and the gap that started look wave 2.
4 //! [`makeover_layout::Region::Widget`] is what makes an assembly sayable at
5 //! all, and the picture it holds is [`quasi_router::Image`], which carries a
6 //! source. That pairing is why the widget tier sits above the description
7 //! suite rather than inside it; the crate docs carry the argument.
8 //!
9 //! Proved against three real MNW pages before it moved here, which is the
10 //! order Max asked for: a widget earns the shared crate by working somewhere
11 //! first. What stayed behind in the app is the Askama glue, and nothing else.
12 //!
13 //! # Why the body is the frames and nothing else
14 //!
15 //! A carousel is a set of frames, a position, prev/next and a strip of dots.
16 //! Only the first of those is *content*; the rest is chrome, and chrome is what
17 //! a renderer that recognises the name draws its own way. A webview draws
18 //! buttons over the frame, a terminal draws a pager with a count, egui draws a
19 //! selector, and none of them owes the others a carousel primitive.
20 //!
21 //! So the description says "an ordered set of pictures, called a carousel" and
22 //! stops. That is the whole of what every host agrees on.
23 //!
24 //! # What a renderer that has never heard of a carousel does
25 //!
26 //! It walks the body and draws the pictures in order. Nothing is lost: every
27 //! frame is content, in sequence, and the reader can see all of them.
28 //!
29 //! This is a **better** fallback than the one it replaced. MNW's shipped
30 //! partial showed the first frame and made the other two unreachable without
31 //! JS, because the controls that would reach them are the part that needs
32 //! scripting. Here the unenhanced rendering is the whole gallery, and the
33 //! script's job is to collapse it to one at a time rather than to unlock the
34 //! rest. Progressive enhancement in the direction that degrades to *more*
35 //! content instead of less.
36
37 use quasi_declare::declare;
38 use quasi_router::RegionKind;
39
40 /// What the recognising renderer keys on. Never interpreted by the description.
41 pub const NAME: &str = "carousel";
42
43 /// The region kind, which carries the widget's name.
44 ///
45 /// A supplier because [`RegionKind::Widget`] is a struct variant and a
46 /// declaration admits no aggregate. `RegionKind` is one of the deferred leaves,
47 /// so a supplier answering one sits outside the shape population by rule rather
48 /// than by exception.
49 fn kind() -> RegionKind {
50 RegionKind::Widget {
51 name: NAME.to_string(),
52 }
53 }
54
55 /// A picture's own dimensions.
56 ///
57 /// Two named fields rather than a pair, because a declaration reads a field by
58 /// name and `.0` is not a name it can say. The rule is older than this widget:
59 /// unnamed data gets named fields the first time a description has to read it.
60 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
61 pub struct Size {
62 /// Across.
63 pub width: u32,
64 /// Down.
65 pub height: u32,
66 }
67
68 /// One frame: a picture and what it says.
69 ///
70 /// Deliberately not an app's own frame type. Those carry what a template layer
71 /// happened to need; this is what the widget needs, and keeping them apart is
72 /// what let this leave MNW without dragging the template layer with it.
73 #[derive(Debug, Clone, PartialEq, Eq)]
74 pub struct Frame {
75 /// Where the picture is.
76 pub src: String,
77 /// What the picture says, for anything not showing it.
78 pub alt: String,
79 /// A visible line under it, where there is one.
80 pub caption: Option<String>,
81 /// The picture's own dimensions, where the caller knows them.
82 ///
83 /// What lets the renderer hold the frame's place from first paint. Without
84 /// it the frame occupies nothing until the bytes land and then takes its
85 /// full height at once, which measured as a 478px jump on MNW's landing
86 /// page.
87 pub intrinsic: Option<Size>,
88 /// Whether this picture may wait for the bytes.
89 ///
90 /// True of every frame but the one on screen, and the position that decides
91 /// it is [`Gallery::new`]'s to know. It is carried here because a
92 /// description reads a fact and counts nothing: a declaration has no index
93 /// and wants none.
94 ///
95 /// Getting this backwards is what MNW's old partial did by lazily loading
96 /// all three, including the one the visitor was already looking at. That
97 /// delays the only picture that matters and buys nothing, because the other
98 /// two are display:none and were never going to be fetched early anyway.
99 pub defer: bool,
100 }
101
102 impl Frame {
103 /// A frame at a source.
104 pub fn new(src: impl Into<String>, alt: impl Into<String>) -> Self {
105 Self {
106 src: src.into(),
107 alt: alt.into(),
108 caption: None,
109 intrinsic: None,
110 defer: false,
111 }
112 }
113
114 /// The picture's own dimensions.
115 #[must_use]
116 pub const fn intrinsic(mut self, width: u32, height: u32) -> Self {
117 self.intrinsic = Some(Size { width, height });
118 self
119 }
120
121 /// A visible line under it.
122 #[must_use]
123 pub fn caption(mut self, caption: impl Into<String>) -> Self {
124 self.caption = Some(caption.into());
125 self
126 }
127 }
128
129 /// An ordered set of frames, with the one on screen already decided.
130 ///
131 /// The read the two declarations below are written against. It exists because
132 /// a description reads facts and derives none: which frame is on screen is a
133 /// property of position, and a declaration has no index to ask for it. So the
134 /// one pass that knows the order happens here, once, and every frame arrives
135 /// carrying its own answer.
136 ///
137 /// Split from the widget rather than folded into it because a set of pictures
138 /// in order is worth having on its own: a gallery that does not page is this
139 /// without the widget name around it, and that is the second consumer this
140 /// module expects.
141 #[derive(Debug, Clone, PartialEq, Eq, Default)]
142 pub struct Gallery {
143 /// The frames, in order. The first is the one on screen.
144 pub frames: Vec<Frame>,
145 }
146
147 impl Gallery {
148 /// The frames in order, with everything behind the first marked to wait.
149 ///
150 /// Eager for the one on screen, lazy for the others, which is the case that
151 /// proves loading cannot be a single setting the renderer picks: both
152 /// answers are correct, in one widget, at one moment.
153 pub fn new(frames: impl IntoIterator<Item = Frame>) -> Self {
154 Self {
155 frames: frames
156 .into_iter()
157 .enumerate()
158 .map(|(i, frame)| Frame {
159 defer: i > 0,
160 ..frame
161 })
162 .collect(),
163 }
164 }
165 }
166
167 impl FromIterator<Frame> for Gallery {
168 fn from_iter<I: IntoIterator<Item = Frame>>(frames: I) -> Self {
169 Self::new(frames)
170 }
171 }
172
173 declare! {
174 /// The frames as description nodes.
175 ///
176 /// `Fit::Natural` is the whole reason `Fit` has three members rather than
177 /// the one MNW uses at 15 of its 17 other sites. A screenshot cropped to
178 /// fill its box is a screenshot with its edges cut off, and the edges of a
179 /// screenshot of an interface are where the interface is.
180 pub shape frames(gallery: &Gallery) -> Vec<Node>;
181
182 for frame in gallery.frames.iter() {
183 picture &frame.src &frame.alt {
184 fit Natural;
185 lazy when frame.defer;
186 for size in frame.intrinsic.iter() {
187 intrinsic size.width size.height;
188 }
189 for line in frame.caption.iter() {
190 caption line;
191 }
192 }
193 }
194 }
195
196 declare! {
197 /// A carousel under an address.
198 ///
199 /// The id is the region's, which is how a fragment finds its way back to
200 /// the right place, so it has to be unique on the page the way every slot
201 /// id does.
202 ///
203 /// # The chrome is not here, and that is the whole of what this widget is
204 ///
205 /// A position, prev, next and a dot strip were listed as parts of this
206 /// assembly when it was designed, and none of them is in the body. They are
207 /// chrome, and chrome is what a recognising renderer draws its own way --
208 /// which is why they came out of here and went into the renderers, once,
209 /// for every widget.
210 ///
211 /// What this says is `Showing::One`: an ordered set of pictures, one of
212 /// them showing, and the reader can change which. Everything a host draws
213 /// around that falls out of it, so a carousel needs no code in any renderer
214 /// and a second assembly saying the same thing gets the same chrome for
215 /// free.
216 pub shape carousel(id: &str, gallery: &Gallery) -> Slot;
217
218 region id as kind() {
219 showing_one 0;
220 extend frames(gallery);
221 }
222 }
223
224 #[cfg(test)]
225 mod tests {
226 use super::*;
227 use quasi_http::Serves as _;
228
229 fn render(id: &str, items: Vec<Frame>) -> String {
230 let gallery = Gallery::new(items);
231 quasi_webview::Webview::new().fragment(&quasi_router::Node::Region(carousel(id, &gallery)))
232 }
233
234 fn three() -> Vec<Frame> {
235 vec![
236 Frame::new("/a.png", "The library, mid-import").caption("Library"),
237 Frame::new("/b.png", "A project page with two items"),
238 Frame::new("/c.png", "The payouts table"),
239 ]
240 }
241
242 #[test]
243 fn the_name_is_on_the_region_for_a_renderer_that_knows_it() {
244 let html = render("landing-shots", three());
245 assert!(html.contains(r#"data-widget="carousel""#), "{html}");
246 assert!(html.contains(r#"id="landing-shots""#), "{html}");
247 }
248
249 #[test]
250 fn every_frame_is_in_the_markup_and_not_only_the_first() {
251 // The fallback this replaces showed frame one and made the rest
252 // unreachable without JS. The whole gallery is here, in order.
253 let html = render("g", three());
254 for src in ["/a.png", "/b.png", "/c.png"] {
255 assert!(html.contains(src), "{src} missing from {html}");
256 }
257 let first = html.find("/a.png").unwrap();
258 let second = html.find("/b.png").unwrap();
259 let third = html.find("/c.png").unwrap();
260 assert!(first < second && second < third, "frames out of order");
261 }
262
263 #[test]
264 fn a_frame_says_what_it_shows_to_someone_not_looking_at_it() {
265 let html = render("g", three());
266 assert!(html.contains(r#"alt="The library, mid-import""#), "{html}");
267 assert!(html.contains(r#"alt="The payouts table""#), "{html}");
268 }
269
270 #[test]
271 fn a_captioned_frame_is_a_figure_and_a_bare_one_is_not() {
272 let html = render("g", three());
273 // One caption across the three, so one figure element.
274 assert_eq!(html.matches("<figure").count(), 1, "{html}");
275 assert!(html.contains("Library</figcaption>"), "{html}");
276 }
277
278 #[test]
279 fn a_frame_that_knows_its_size_reserves_its_space() {
280 // The 478px jump this exists to stop: without width/height the browser
281 // gives the picture no room until the bytes land.
282 let html = render(
283 "g",
284 vec![Frame::new("/a.png", "Alpha").intrinsic(5120, 3412)],
285 );
286 assert!(html.contains(r#"width="5120" height="3412""#), "{html}");
287 }
288
289 #[test]
290 fn a_frame_that_does_not_know_its_size_says_nothing() {
291 // A creator upload. Reserving the wrong room is worse than none, so an
292 // absent size must not become a guessed one.
293 let html = render("g", vec![Frame::new("/a.png", "Alpha")]);
294 assert!(!html.contains("width="), "{html}");
295 assert!(!html.contains("height="), "{html}");
296 }
297
298 #[test]
299 fn the_visible_frame_is_fetched_now_and_the_rest_can_wait() {
300 // Both answers in one widget at one moment, which is why loading is the
301 // description's to say rather than a renderer-wide setting.
302 let html = render("g", three());
303 assert_eq!(
304 html.matches("loading=\"lazy\"").count(),
305 2,
306 "expected the two offscreen frames only: {html}"
307 );
308 // Everything before the second frame's source is the first frame, so
309 // nothing in that span may defer: it is the picture already on screen.
310 let upto_second = &html[..html.find("/b.png").unwrap()];
311 assert!(
312 !upto_second.contains("loading=\"lazy\""),
313 "the visible frame must not be deferred: {html}"
314 );
315 }
316
317 #[test]
318 fn a_screenshot_keeps_its_own_shape() {
319 // Natural is the default and emits no attribute, so the assertion is
320 // that nothing asked for a crop. A cropped screenshot loses its edges,
321 // which is where the interface is.
322 let html = render("g", three());
323 assert!(!html.contains("data-fit"), "{html}");
324 }
325
326 #[test]
327 fn a_hostile_source_cannot_break_out_of_the_attribute() {
328 let html = render(
329 "g",
330 vec![Frame::new(r#"x" onerror="alert(1)"#, "</title><script>")],
331 );
332 assert!(!html.contains("onerror=\"alert"), "{html}");
333 assert!(!html.contains("<script>"), "{html}");
334 }
335
336 #[test]
337 fn the_empty_case_is_a_region_with_nothing_in_it() {
338 // Three MNW pages call this and one of them has an empty gallery
339 // whenever the creator uploaded nothing. It has to be a carousel with
340 // no frames rather than a panic or a stray element.
341 let html = render("g", Vec::new());
342 assert!(html.contains(r#"data-widget="carousel""#), "{html}");
343 assert!(!html.contains("<img"), "{html}");
344 }
345
346 #[test]
347 fn the_gallery_decides_which_frame_waits_and_the_description_only_reads_it() {
348 // The one thing the read is for. A declaration has no index, so the
349 // pass that knows the order happens here and every frame arrives
350 // carrying its own answer.
351 let gallery = Gallery::new(three());
352 let waiting: Vec<bool> = gallery.frames.iter().map(|frame| frame.defer).collect();
353 assert_eq!(waiting, vec![false, true, true]);
354
355 // And an empty set decides nothing, rather than reaching for a first
356 // frame that is not there.
357 assert!(Gallery::new(Vec::new()).frames.is_empty());
358 }
359
360 #[test]
361 fn the_chrome_is_derived_and_no_renderer_was_told_what_a_carousel_is() {
362 // What this widget stopped carrying. The row is not in the body and not
363 // in any renderer's knowledge of the name; it falls out of `Showing::One`
364 // plus frames that carry no label, which is what makes the next
365 // assembly saying the same thing free.
366 let html = render("shots", three());
367
368 assert!(html.contains("data-showing=\"one\""), "{html}");
369 assert!(html.contains("data-shows=\"previous\""), "{html}");
370 assert!(html.contains("data-shows=\"next\""), "{html}");
371 assert!(html.contains(">1 / 3</span>"), "{html}");
372 // And no dot strip, which is what it replaces: two of the three galleries
373 // this ships on are creator uploads of arbitrary length.
374 assert!(!html.contains("dot"), "{html}");
375 }
376 }
377