Skip to main content

max / quasi

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