//! An ordered set of frames, said once. //! //! The widget tier's first consumer (`c0b63ea9`), and the gap that started //! look wave 2. [`makeover_layout::Region::Widget`] arrived at 0.20.0 to make //! this sayable and [`makeover_layout::Image`] at 0.21.0 because the first //! attempt found nothing named a picture. //! //! Proved against three real MNW pages before it moved here, which is the //! order Max asked for: a widget earns the shared crate by working somewhere //! first. What stayed behind in the app is the Askama glue, and nothing else. //! //! # Why the body is the frames and nothing else //! //! A carousel is a set of frames, a position, prev/next and a strip of dots. //! Only the first of those is *content*; the rest is chrome, and chrome is what //! a renderer that recognises the name draws its own way. A webview draws //! buttons over the frame, a terminal draws a pager with a count, egui draws a //! selector, and none of them owes the others a carousel primitive. //! //! So the description says "an ordered set of pictures, called a carousel" and //! stops. That is the whole of what every host agrees on. //! //! # What a renderer that has never heard of a carousel does //! //! It walks the body and draws the pictures in order. Nothing is lost: every //! frame is content, in sequence, and the reader can see all of them. //! //! This is a **better** fallback than the one it replaced. MNW's shipped //! partial showed the first frame and made the other two unreachable without //! JS, because the controls that would reach them are the part that needs //! scripting. Here the unenhanced rendering is the whole gallery, and the //! script's job is to collapse it to one at a time rather than to unlock the //! rest. Progressive enhancement in the direction that degrades to *more* //! content instead of less. use makeover_layout::Fit; use quasi_router::{Node, Picture, Slot}; /// What the recognising renderer keys on. Never interpreted by the description. pub const NAME: &str = "carousel"; /// One frame: a picture and what it says. /// /// Deliberately not an app's own frame type. Those carry what a template layer /// happened to need; this is what the widget needs, and keeping them apart is /// what let this leave MNW without dragging the template layer with it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Frame { /// Where the picture is. pub src: String, /// What the picture says, for anything not showing it. pub alt: String, /// A visible line under it, where there is one. pub caption: Option, /// The picture's own dimensions, where the caller knows them. /// /// What lets the renderer hold the frame's place from first paint. Without /// it the frame occupies nothing until the bytes land and then takes its /// full height at once, which measured as a 478px jump on MNW's landing /// page. pub intrinsic: Option<(u32, u32)>, } impl Frame { /// A frame at a source. pub fn new(src: impl Into, alt: impl Into) -> Self { Self { src: src.into(), alt: alt.into(), caption: None, intrinsic: None, } } /// The picture's own dimensions. #[must_use] pub const fn intrinsic(mut self, width: u32, height: u32) -> Self { self.intrinsic = Some((width, height)); self } /// A visible line under it. #[must_use] pub fn caption(mut self, caption: impl Into) -> Self { self.caption = Some(caption.into()); self } } /// The frames as description nodes. /// /// Split out from [`carousel`] because a set of pictures in order is worth /// having on its own: a gallery that does not page is this without the widget /// name around it, and that is the second consumer this module expects. pub fn frames(frames: impl IntoIterator) -> impl Iterator { frames.into_iter().enumerate().map(|(i, frame)| { // Natural, and it is the whole reason `Fit` has three members rather // than the one MNW uses at 15 of its 17 other sites. A screenshot // cropped to fill its box is a screenshot with its edges cut off, and // the edges of a screenshot of an interface are where the interface is. let mut picture = Picture::new(frame.src, frame.alt).fit(Fit::Natural); if let Some((w, h)) = frame.intrinsic { picture = picture.intrinsic(w, h); } // The first frame is the one on screen, and the rest are not. Eager for // the one, lazy for the others -- which is the case that proves loading // cannot be a single setting the renderer picks: both answers are // correct, in one widget, at one moment. // // Getting this backwards is what MNW's old partial did by lazily // loading all three, including the one the visitor was already looking // at. That delays the only picture that matters and buys nothing, // because the other two are display:none and were never going to be // fetched early anyway. if i > 0 { picture = picture.lazy(); } Node::Image(match frame.caption { Some(caption) => picture.caption(caption), None => picture, }) }) } /// A carousel under an address. /// /// The id is the region's, which is how a fragment finds its way back to the /// right place, so it has to be unique on the page the way every slot id does. /// /// # The chrome is not here, and that is the whole of what this widget is /// /// A position, prev, next and a dot strip were listed as parts of this assembly /// when it was designed, and none of them is in the body. They are chrome, and /// chrome is what a recognising renderer draws its own way -- which is why they /// came out of here and went into the renderers, once, for every widget. /// /// What this says is `Showing::One`: an ordered set of pictures, one of them /// showing, and the reader can change which. Everything a host draws around /// that falls out of it, so a carousel needs no code in any renderer and a /// second assembly saying the same thing gets the same chrome for free. pub fn carousel(id: &str, items: impl IntoIterator) -> Slot { Slot::widget(id, NAME).extend(frames(items)).showing_one(0) } #[cfg(test)] mod tests { use super::*; use quasi_http::Serves as _; fn render(id: &str, items: Vec) -> String { quasi_webview::Webview::new().fragment(&Node::Region(carousel(id, items))) } fn three() -> Vec { vec![ Frame::new("/a.png", "The library, mid-import").caption("Library"), Frame::new("/b.png", "A project page with two items"), Frame::new("/c.png", "The payouts table"), ] } #[test] fn the_name_is_on_the_region_for_a_renderer_that_knows_it() { let html = render("landing-shots", three()); assert!(html.contains(r#"data-widget="carousel""#), "{html}"); assert!(html.contains(r#"id="landing-shots""#), "{html}"); } #[test] fn every_frame_is_in_the_markup_and_not_only_the_first() { // The fallback this replaces showed frame one and made the rest // unreachable without JS. The whole gallery is here, in order. let html = render("g", three()); for src in ["/a.png", "/b.png", "/c.png"] { assert!(html.contains(src), "{src} missing from {html}"); } let first = html.find("/a.png").unwrap(); let second = html.find("/b.png").unwrap(); let third = html.find("/c.png").unwrap(); assert!(first < second && second < third, "frames out of order"); } #[test] fn a_frame_says_what_it_shows_to_someone_not_looking_at_it() { let html = render("g", three()); assert!(html.contains(r#"alt="The library, mid-import""#), "{html}"); assert!(html.contains(r#"alt="The payouts table""#), "{html}"); } #[test] fn a_captioned_frame_is_a_figure_and_a_bare_one_is_not() { let html = render("g", three()); // One caption across the three, so one figure element. assert_eq!(html.matches(""), "{html}"); } #[test] fn a_frame_that_knows_its_size_reserves_its_space() { // The 478px jump this exists to stop: without width/height the browser // gives the picture no room until the bytes land. let html = render( "g", vec![Frame::new("/a.png", "Alpha").intrinsic(5120, 3412)], ); assert!(html.contains(r#"width="5120" height="3412""#), "{html}"); } #[test] fn a_frame_that_does_not_know_its_size_says_nothing() { // A creator upload. Reserving the wrong room is worse than none, so an // absent size must not become a guessed one. let html = render("g", vec![Frame::new("/a.png", "Alpha")]); assert!(!html.contains("width="), "{html}"); assert!(!html.contains("height="), "{html}"); } #[test] fn the_visible_frame_is_fetched_now_and_the_rest_can_wait() { // Both answers in one widget at one moment, which is why loading is the // description's to say rather than a renderer-wide setting. let html = render("g", three()); assert_eq!( html.matches("loading=\"lazy\"").count(), 2, "expected the two offscreen frames only: {html}" ); // Everything before the second frame's source is the first frame, so // nothing in that span may defer: it is the picture already on screen. let upto_second = &html[..html.find("/b.png").unwrap()]; assert!( !upto_second.contains("loading=\"lazy\""), "the visible frame must not be deferred: {html}" ); } #[test] fn a_screenshot_keeps_its_own_shape() { // Natural is the default and emits no attribute, so the assertion is // that nothing asked for a crop. A cropped screenshot loses its edges, // which is where the interface is. let html = render("g", three()); assert!(!html.contains("data-fit"), "{html}"); } #[test] fn a_hostile_source_cannot_break_out_of_the_attribute() { let html = render( "g", vec![Frame::new(r#"x" onerror="alert(1)"#, "