|
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 |
+ |
pub fn carousel(id: &str, items: impl IntoIterator<Item = Frame>) -> Slot {
|
|
130 |
+ |
Slot::widget(id, NAME).extend(frames(items))
|
|
131 |
+ |
}
|
|
132 |
+ |
|
|
133 |
+ |
#[cfg(test)]
|
|
134 |
+ |
mod tests {
|
|
135 |
+ |
use super::*;
|
|
136 |
+ |
use quasi_http::Serves as _;
|
|
137 |
+ |
|
|
138 |
+ |
fn render(id: &str, items: Vec<Frame>) -> String {
|
|
139 |
+ |
quasi_webview::Webview::new().fragment(&Node::Region(carousel(id, items)))
|
|
140 |
+ |
}
|
|
141 |
+ |
|
|
142 |
+ |
fn three() -> Vec<Frame> {
|
|
143 |
+ |
vec![
|
|
144 |
+ |
Frame::new("/a.png", "The library, mid-import").caption("Library"),
|
|
145 |
+ |
Frame::new("/b.png", "A project page with two items"),
|
|
146 |
+ |
Frame::new("/c.png", "The payouts table"),
|
|
147 |
+ |
]
|
|
148 |
+ |
}
|
|
149 |
+ |
|
|
150 |
+ |
#[test]
|
|
151 |
+ |
fn the_name_is_on_the_region_for_a_renderer_that_knows_it() {
|
|
152 |
+ |
let html = render("landing-shots", three());
|
|
153 |
+ |
assert!(html.contains(r#"data-widget="carousel""#), "{html}");
|
|
154 |
+ |
assert!(html.contains(r#"id="landing-shots""#), "{html}");
|
|
155 |
+ |
}
|
|
156 |
+ |
|
|
157 |
+ |
#[test]
|
|
158 |
+ |
fn every_frame_is_in_the_markup_and_not_only_the_first() {
|
|
159 |
+ |
// The fallback this replaces showed frame one and made the rest
|
|
160 |
+ |
// unreachable without JS. The whole gallery is here, in order.
|
|
161 |
+ |
let html = render("g", three());
|
|
162 |
+ |
for src in ["/a.png", "/b.png", "/c.png"] {
|
|
163 |
+ |
assert!(html.contains(src), "{src} missing from {html}");
|
|
164 |
+ |
}
|
|
165 |
+ |
let first = html.find("/a.png").unwrap();
|
|
166 |
+ |
let second = html.find("/b.png").unwrap();
|
|
167 |
+ |
let third = html.find("/c.png").unwrap();
|
|
168 |
+ |
assert!(first < second && second < third, "frames out of order");
|
|
169 |
+ |
}
|
|
170 |
+ |
|
|
171 |
+ |
#[test]
|
|
172 |
+ |
fn a_frame_says_what_it_shows_to_someone_not_looking_at_it() {
|
|
173 |
+ |
let html = render("g", three());
|
|
174 |
+ |
assert!(html.contains(r#"alt="The library, mid-import""#), "{html}");
|
|
175 |
+ |
assert!(html.contains(r#"alt="The payouts table""#), "{html}");
|
|
176 |
+ |
}
|
|
177 |
+ |
|
|
178 |
+ |
#[test]
|
|
179 |
+ |
fn a_captioned_frame_is_a_figure_and_a_bare_one_is_not() {
|
|
180 |
+ |
let html = render("g", three());
|
|
181 |
+ |
// One caption across the three, so one figure element.
|
|
182 |
+ |
assert_eq!(html.matches("<figure").count(), 1, "{html}");
|
|
183 |
+ |
assert!(html.contains("Library</figcaption>"), "{html}");
|
|
184 |
+ |
}
|
|
185 |
+ |
|
|
186 |
+ |
#[test]
|
|
187 |
+ |
fn a_frame_that_knows_its_size_reserves_its_space() {
|
|
188 |
+ |
// The 478px jump this exists to stop: without width/height the browser
|
|
189 |
+ |
// gives the picture no room until the bytes land.
|
|
190 |
+ |
let html = render(
|
|
191 |
+ |
"g",
|
|
192 |
+ |
vec![Frame::new("/a.png", "Alpha").intrinsic(5120, 3412)],
|
|
193 |
+ |
);
|
|
194 |
+ |
assert!(html.contains(r#"width="5120" height="3412""#), "{html}");
|
|
195 |
+ |
}
|
|
196 |
+ |
|
|
197 |
+ |
#[test]
|
|
198 |
+ |
fn a_frame_that_does_not_know_its_size_says_nothing() {
|
|
199 |
+ |
// A creator upload. Reserving the wrong room is worse than none, so an
|
|
200 |
+ |
// absent size must not become a guessed one.
|
|
201 |
+ |
let html = render("g", vec![Frame::new("/a.png", "Alpha")]);
|
|
202 |
+ |
assert!(!html.contains("width="), "{html}");
|
|
203 |
+ |
assert!(!html.contains("height="), "{html}");
|
|
204 |
+ |
}
|
|
205 |
+ |
|
|
206 |
+ |
#[test]
|
|
207 |
+ |
fn the_visible_frame_is_fetched_now_and_the_rest_can_wait() {
|
|
208 |
+ |
// Both answers in one widget at one moment, which is why loading is the
|
|
209 |
+ |
// description's to say rather than a renderer-wide setting.
|
|
210 |
+ |
let html = render("g", three());
|
|
211 |
+ |
assert_eq!(
|
|
212 |
+ |
html.matches("loading=\"lazy\"").count(),
|
|
213 |
+ |
2,
|
|
214 |
+ |
"expected the two offscreen frames only: {html}"
|
|
215 |
+ |
);
|
|
216 |
+ |
// Everything before the second frame's source is the first frame, so
|
|
217 |
+ |
// nothing in that span may defer: it is the picture already on screen.
|
|
218 |
+ |
let upto_second = &html[..html.find("/b.png").unwrap()];
|
|
219 |
+ |
assert!(
|
|
220 |
+ |
!upto_second.contains("loading=\"lazy\""),
|
|
221 |
+ |
"the visible frame must not be deferred: {html}"
|
|
222 |
+ |
);
|
|
223 |
+ |
}
|
|
224 |
+ |
|
|
225 |
+ |
#[test]
|
|
226 |
+ |
fn a_screenshot_keeps_its_own_shape() {
|
|
227 |
+ |
// Natural is the default and emits no attribute, so the assertion is
|
|
228 |
+ |
// that nothing asked for a crop. A cropped screenshot loses its edges,
|
|
229 |
+ |
// which is where the interface is.
|
|
230 |
+ |
let html = render("g", three());
|
|
231 |
+ |
assert!(!html.contains("data-fit"), "{html}");
|
|
232 |
+ |
}
|
|
233 |
+ |
|
|
234 |
+ |
#[test]
|
|
235 |
+ |
fn a_hostile_source_cannot_break_out_of_the_attribute() {
|
|
236 |
+ |
let html = render(
|
|
237 |
+ |
"g",
|
|
238 |
+ |
vec![Frame::new(r#"x" onerror="alert(1)"#, "</title><script>")],
|
|
239 |
+ |
);
|
|
240 |
+ |
assert!(!html.contains("onerror=\"alert"), "{html}");
|
|
241 |
+ |
assert!(!html.contains("<script>"), "{html}");
|
|
242 |
+ |
}
|
|
243 |
+ |
|
|
244 |
+ |
#[test]
|
|
245 |
+ |
fn the_empty_case_is_a_region_with_nothing_in_it() {
|
|
246 |
+ |
// Three MNW pages call this and one of them has an empty gallery
|
|
247 |
+ |
// whenever the creator uploaded nothing. It has to be a carousel with
|
|
248 |
+ |
// no frames rather than a panic or a stray element.
|
|
249 |
+ |
let html = render("g", Vec::new());
|
|
250 |
+ |
assert!(html.contains(r#"data-widget="carousel""#), "{html}");
|
|
251 |
+ |
assert!(!html.contains("<img"), "{html}");
|
|
252 |
+ |
}
|
|
253 |
+ |
}
|