Skip to main content

frus_widgets/
wheel.rs

1//! [`ListWheel`]: the scrolling cylinder of values you spin to pick one.
2//!
3//! The selected row is in the middle and the rest curve away above and below, packing
4//! together and shrinking as they go. It is the control behind every date, time and
5//! duration picker that is **not** a calendar — and the one an application reaches for
6//! when it needs a picker of its own: a quantity, a font size, a unit.
7//!
8//! ## What a cylinder is, and what this one is
9//!
10//! A true cylinder needs a rotation about a **horizontal** axis and a perspective divide.
11//! The paint here transforms a subtree by an [`frus_core::Affine`], and an affine maps
12//! parallel lines to parallel lines — so it cannot produce the trapezoid a tipped row
13//! actually is, its near edge wider than its far one. **A real cylinder is not
14//! expressible**, and saying it is would be the sort of claim that is found out on a
15//! screen rather than in a test.
16//!
17//! What is expressible is every part of a cylinder that a reader notices:
18//!
19//! - rows **pack together** towards the ends, their centres at `r·sin θ` rather than at
20//!   the flat distance the strip would put them;
21//! - rows **squash** vertically by `cos θ`, which is foreshortening exactly;
22//! - rows **narrow** slightly as they recede, which is the perspective the affine can
23//!   still express — a scale, being uniform across the row, rather than a taper;
24//! - rows **fade** with the same `cos θ`, which is where the missing taper's share of the
25//!   illusion goes.
26//!
27//! What is left out is the taper itself, and at the angles a wheel is read at it is the
28//! one part nobody looks for. Anything past a **quarter turn** has gone over the horizon
29//! and is not drawn at all.
30//!
31//! ## What it is made of
32//!
33//! Nothing new: the spin, the fling and the settling-on-a-row are the paged scrollable's
34//! ([`crate::PageView`]), with each row a page of its own height. So a wheel inherits the
35//! release that springs to the nearest row rather than stopping between two, the
36//! virtualised window — a wheel of three thousand minutes costs what a wheel of ten does
37//! — and the requested-row-on-the-first-frame rule. What a wheel adds is the geometry
38//! below and a way of saying, to a reader who cannot see it, which value is chosen.
39
40use std::f32::consts::FRAC_PI_2;
41
42use frus_core::{Rect, Scene};
43use frus_layout::{Dimension, Style};
44
45use crate::interaction::Status;
46use crate::pageview::PagedView;
47use crate::physics::ScrollPhysics;
48use crate::scroll::Axis;
49use crate::theme::Theme;
50use crate::widget::Widget;
51
52/// The shape of the cylinder rows are laid on.
53#[derive(Copy, Clone, Debug, PartialEq)]
54pub struct WheelGeometry {
55    /// The cylinder's diameter as a multiple of the viewport's height. **Larger is
56    /// flatter**: at a diameter of twice the viewport only the middle thirty degrees of
57    /// the cylinder are ever in front of it, and thirty degrees of a turn is a slope
58    /// rather than a wheel.
59    pub diameter_ratio: f32,
60    /// How much a row narrows per pixel it has receded. `0.0` leaves every row the full
61    /// width, which reads as a fold rather than a turn.
62    pub perspective: f32,
63}
64
65impl Default for WheelGeometry {
66    /// A diameter only a little larger than the wheel is tall, which puts the ends of
67    /// the visible strip most of the way round to the horizon and is what makes a picker
68    /// look like a picker rather than like a list with the ends dimmed.
69    ///
70    /// These are the reference's own numbers for its picker, and its comment on them is
71    /// that they were **eyeballed against a real one**. That is worth repeating rather
72    /// than rederiving: the geometry here is deliberately not a cylinder (see the module
73    /// documentation), so a figure argued from first principles would be arguing about
74    /// the wrong shape.
75    fn default() -> Self {
76        Self {
77            diameter_ratio: 1.07,
78            perspective: 0.003,
79        }
80    }
81}
82
83/// Where one row has ended up on the cylinder, and what it looks like there.
84#[derive(Copy, Clone, Debug, PartialEq)]
85pub struct RowOnWheel {
86    /// How far round the cylinder the row has turned, in radians, signed.
87    pub angle: f32,
88    /// Where the row's **centre** sits, in pixels from the viewport's centre — always
89    /// nearer to it than the flat strip would have put it.
90    pub offset: f32,
91    /// Horizontal scale: the row receding.
92    pub scale_x: f32,
93    /// Vertical scale: the row foreshortened, which is `cos θ`.
94    pub scale_y: f32,
95    /// What is left of the row's opacity.
96    pub opacity: f32,
97}
98
99impl WheelGeometry {
100    /// The cylinder's radius in a viewport `extent` pixels along.
101    pub fn radius(&self, extent: f32) -> f32 {
102        (extent * self.diameter_ratio / 2.0).max(1.0)
103    }
104
105    /// Where a row goes, given the viewport's extent and where the row's centre would
106    /// sit on an **unrolled** strip, measured from the viewport's centre.
107    ///
108    /// `None` once the row has turned past a quarter — it has gone over the horizon, and
109    /// a row drawn there would be a line of text lying edge-on.
110    pub fn row(&self, extent: f32, flat: f32) -> Option<RowOnWheel> {
111        let radius = self.radius(extent);
112        // Arc length over radius: the strip is what is wrapped round the cylinder, so the
113        // flat distance **is** the arc, and this is the angle it subtends.
114        let angle = flat / radius;
115        if angle.abs() >= FRAC_PI_2 {
116            return None;
117        }
118        let cos = angle.cos();
119        // How far the row has receded from the plane of the viewport.
120        let z = radius * (1.0 - cos);
121        Some(RowOnWheel {
122            angle,
123            offset: radius * angle.sin(),
124            scale_x: 1.0 / (1.0 + self.perspective * z),
125            scale_y: cos,
126            opacity: cos,
127        })
128    }
129}
130
131/// A scrolling cylinder of rows, resting on one of them. See the module documentation.
132///
133/// ```
134/// use frus_widgets::{text, ListWheel};
135///
136/// let _minutes: ListWheel<usize> = ListWheel::new(60, 34.0, |i| text(format!("{i:02}")))
137///     .height(180.0)
138///     .selected(15)
139///     .on_selected(|i| i)
140///     .label(|i| format!("{i} minutes"));
141/// ```
142pub struct ListWheel<Msg = crate::callback::Callback> {
143    count: usize,
144    extent: f32,
145    geometry: WheelGeometry,
146    selected: usize,
147    build: Box<dyn Fn(usize) -> Box<dyn Widget<Msg>>>,
148    on_selected: Option<Box<dyn Fn(usize) -> Msg>>,
149    label: Option<Box<dyn Fn(usize) -> String>>,
150    physics: Option<ScrollPhysics>,
151    width: Dimension,
152    height: Dimension,
153    /// Whether the width and the height were given; an axis that was not takes the room on
154    /// offer, as the reference's does (milestone 590).
155    sized: (bool, bool),
156    flex: f32,
157}
158
159impl<Msg> ListWheel<Msg> {
160    /// A wheel of `count` rows, each `extent` pixels tall, built on demand.
161    pub fn new<W: Widget<Msg> + 'static>(
162        count: usize,
163        extent: f32,
164        build: impl Fn(usize) -> W + 'static,
165    ) -> Self {
166        Self {
167            count,
168            extent: extent.max(1.0),
169            geometry: WheelGeometry::default(),
170            selected: 0,
171            build: Box::new(move |index| Box::new(build(index)) as Box<dyn Widget<Msg>>),
172            on_selected: None,
173            label: None,
174            physics: None,
175            width: Dimension::Auto,
176            height: Dimension::Length(180.0),
177            sized: (false, false),
178            flex: 0.0,
179        }
180    }
181
182    /// The row the application says is chosen. Applied on the first frame and on every
183    /// change, the way [`crate::PageView::page`] is — so a wheel opens on the value it
184    /// holds rather than on the first one and then jumping.
185    pub fn selected(mut self, index: usize) -> Self {
186        self.selected = index;
187        self
188    }
189
190    /// What to send when the wheel comes to rest on a different row.
191    pub fn on_selected<R: crate::callback::IntoMsg<Msg>>(
192        mut self,
193        message: impl Fn(usize) -> R + 'static,
194    ) -> Self {
195        self.on_selected = Some(Box::new(crate::callback::handler1(message)));
196        self
197    }
198
199    /// **What each row is called**, for a reader who cannot see it.
200    ///
201    /// Not optional in spirit: a wheel with no words is a control that announces its
202    /// position as a number out of a count and its value as nothing at all. The index is
203    /// what the caller has; only the caller knows it means half past three.
204    pub fn label(mut self, label: impl Fn(usize) -> String + 'static) -> Self {
205        self.label = Some(Box::new(label));
206        self
207    }
208
209    /// How curved the cylinder is: the diameter as a multiple of the wheel's own height.
210    pub fn diameter_ratio(mut self, ratio: f32) -> Self {
211        self.geometry.diameter_ratio = ratio.max(0.1);
212        self
213    }
214
215    /// How much a receding row narrows. `0.0` for none.
216    pub fn perspective(mut self, perspective: f32) -> Self {
217        self.geometry.perspective = perspective.max(0.0);
218        self
219    }
220
221    /// The scroll physics of the spin — its scroll behaviour's by default
222    /// ([`ScrollBehavior`](crate::ScrollBehavior)).
223    pub fn physics(mut self, physics: ScrollPhysics) -> Self {
224        self.physics = Some(physics);
225        self
226    }
227
228    /// A fixed width.
229    pub fn width(mut self, width: f32) -> Self {
230        self.width = Dimension::Length(width);
231        self.sized.0 = true;
232        self
233    }
234
235    /// A fixed height. A wheel needs one: it is a window onto a cylinder, and a window
236    /// that hugged its content would be the whole strip.
237    pub fn height(mut self, height: f32) -> Self {
238        self.height = Dimension::Length(height);
239        self.sized.1 = true;
240        self
241    }
242
243    /// Grows to fill the space its parent has left along the main axis.
244    pub fn flex(mut self, grow: f32) -> Self {
245        self.flex = grow;
246        self
247    }
248
249    /// The row a reader is on, clamped to what there is.
250    fn chosen(&self) -> usize {
251        self.selected.min(self.count.saturating_sub(1))
252    }
253}
254
255impl<Msg: Clone + 'static> Widget<Msg> for ListWheel<Msg> {
256    fn style(&self) -> Style {
257        Style {
258            width: self.width,
259            height: self.height,
260            flex_grow: self.flex,
261            ..Default::default()
262        }
263    }
264
265    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
266        &[]
267    }
268
269    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
270        // The rows are the picture, and the walk draws them.
271    }
272
273    fn on_click(&self) -> Option<Msg> {
274        None
275    }
276
277    /// **All the room on offer** on an axis nobody sized, as the reference's takes
278    /// (milestone 590) — unless it was told to grow, which says the same thing already.
279    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
280        if self.flex > 0.0 {
281            return crate::widget::FillAxes::NONE;
282        }
283        crate::widget::FillAxes {
284            horizontal: !self.sized.0,
285            vertical: !self.sized.1,
286        }
287    }
288
289    /// The default size is only a default: the room replaces it wherever the layout can
290    /// give the room, and it stands where the reference would have nothing to give.
291    fn soft_extent(&self) -> crate::widget::FillAxes {
292        crate::widget::FillAxes {
293            horizontal: false,
294            vertical: !self.sized.1,
295        }
296    }
297
298    fn page_view(&self) -> Option<PagedView<'_, Msg>> {
299        Some(PagedView {
300            count: self.count,
301            axis: Axis::Vertical,
302            viewport_fraction: 1.0,
303            extent: Some(self.extent),
304            requested: self.chosen(),
305            // The first row rests **centred**, not against the top edge: the middle of a
306            // wheel is where the answer is, so that is where row nought has to be able to
307            // get to. This is also what makes the last row reachable at all.
308            pad_ends: true,
309            snapping: true,
310            wheel: Some(self.geometry),
311            build: &*self.build,
312        })
313    }
314
315    fn on_page_changed(&self, index: usize) -> Option<Msg> {
316        self.on_selected.as_ref().map(|f| f(index))
317    }
318
319    fn scroll_physics(&self) -> Option<ScrollPhysics> {
320        self.physics
321    }
322
323    /// **The wheel is the control, and this is how a reader hears it change.**
324    ///
325    /// Not a list to walk row by row: a wheel is a one-dimensional selector, so it says
326    /// so — the role a platform's own picker reports, the chosen value in the caller's
327    /// words, and the position in the set as the range. A reader gets "half past three,
328    /// 15 of 60", and gets it again, by itself, every time the wheel settles somewhere
329    /// else. Rows carry their own annotations underneath; this is the one that speaks
330    /// for the whole.
331    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
332        let chosen = self.chosen();
333        let mut props = frus_core::SemanticsProperties::new(frus_core::Role::Slider).range(
334            0.0,
335            chosen as f32,
336            self.count.saturating_sub(1) as f32,
337        );
338        props.value = Some(match self.label.as_ref() {
339            Some(label) => label(chosen),
340            // Without words there is still a position, and a position is better than
341            // silence — but it is not an answer to "what is this set to".
342            None => format!("{} of {}", chosen + 1, self.count),
343        });
344        Some(props)
345    }
346
347    fn debug_name(&self) -> &'static str {
348        "ListWheel"
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use crate::{build_ui, Container, Runtime};
356    use frus_core::{Color, Primitive, Size};
357    use std::cell::Cell;
358    use std::rc::Rc;
359
360    const EXTENT: f32 = 34.0;
361    const VIEWPORT: f32 = 200.0;
362
363    fn wheel(count: usize, selected: usize, built: Rc<Cell<usize>>) -> ListWheel<()> {
364        ListWheel::new(count, EXTENT, move |index| {
365            built.set(built.get() + 1);
366            Container::new()
367                .width(160.0)
368                .height(EXTENT)
369                .color(Color::rgb(index as f32 / 60.0, 0.0, 0.0))
370        })
371        .width(160.0)
372        .height(VIEWPORT)
373        .selected(selected)
374    }
375
376    fn ui_of(widget: &ListWheel<()>, runtime: &Runtime) -> crate::Ui<()> {
377        build_ui(
378            widget,
379            Size::new(160.0, VIEWPORT),
380            runtime,
381            &Theme::default(),
382        )
383    }
384
385    /// **The middle of the wheel is where the answer is**, so the row the application
386    /// says is chosen has to be able to get there — and has to be there on the first
387    /// frame rather than after one that showed row nought.
388    #[test]
389    fn the_chosen_row_rests_across_the_middle() {
390        let wheel = wheel(60, 30, Rc::new(Cell::new(0)));
391        let runtime = Runtime::default();
392        let ui = ui_of(&wheel, &runtime);
393        let area = ui.scroll_regions().first().copied().expect("a region");
394        let snap = area.page.expect("a wheel is a paged region");
395        assert_eq!(snap.extent, EXTENT, "one row is one page");
396        assert_eq!(snap.count, 60);
397        assert!(!snap.horizontal, "a wheel turns the short way");
398        // The offset a wheel opens at, and the row it puts across the middle.
399        let along = snap.offset_of(30);
400        assert_eq!(snap.page_at(along), 30);
401        // Both ends are padded by half a viewport less half a row, which is what lets
402        // row nought and row fifty-nine reach the middle at all.
403        let pad = (VIEWPORT - EXTENT) / 2.0;
404        assert!(
405            (area.max_y - (60.0 * EXTENT + 2.0 * pad - VIEWPORT)).abs() < 0.01,
406            "the travel is exactly the last row's own offset: {}",
407            area.max_y
408        );
409        assert!(
410            (area.max_y - snap.offset_of(59)).abs() < 0.01,
411            "which is to say the last row can be reached: {}",
412            area.max_y
413        );
414    }
415
416    /// **A release settles on a row, never between two.** The wheel does not invent this
417    /// — it is the paged scrollable's release — but a wheel that failed to declare
418    /// itself paged would fling like a list and stop wherever it ran out, which is the
419    /// one thing a picker must not do.
420    #[test]
421    fn a_spin_comes_to_rest_on_a_row() {
422        let wheel = wheel(60, 30, Rc::new(Cell::new(0)));
423        let mut runtime = Runtime::default();
424        let area = ui_of(&wheel, &runtime)
425            .scroll_regions()
426            .first()
427            .copied()
428            .expect("a region");
429        let snap = area.page.expect("paged");
430        // Let go from exactly halfway between two rows, so nothing but the direction of
431        // the release can decide which one it goes to — and ask it both ways, because a
432        // wheel that settled on a row and always the same row would pass the first half
433        // of this on its own.
434        let between = snap.offset_of(30) + EXTENT / 2.0;
435        let settle = |runtime: &mut Runtime, velocity: f32| {
436            runtime.scroll.insert(area.id, (0.0, between));
437            runtime.scroll_ballistic.remove(&area.id);
438            assert!(
439                runtime.fling_scroll(area, area.physics, (0.0, velocity)),
440                "a release on a paged region is a spring to a row"
441            );
442            for _ in 0..600 {
443                if !runtime.advance_scroll(&[area], 1.0 / 60.0) {
444                    break;
445                }
446            }
447            runtime.scroll.get(&area.id).copied().unwrap_or_default().1
448        };
449
450        let onwards = settle(&mut runtime, 300.0);
451        let row = (onwards / EXTENT).round();
452        assert!(
453            (onwards - row * EXTENT).abs() < 0.5,
454            "settled on a row and not between two: {onwards} is {} rows",
455            onwards / EXTENT
456        );
457        assert_eq!(
458            snap.page_at(onwards),
459            31,
460            "and on the one it was sent towards"
461        );
462
463        let back = settle(&mut runtime, -300.0);
464        assert!(
465            (back - (back / EXTENT).round() * EXTENT).abs() < 0.5,
466            "and the other way is a row too: {back}"
467        );
468        assert_eq!(snap.page_at(back), 30, "the one behind it");
469    }
470
471    /// **Only the rows the cylinder shows are built.** A wheel of three thousand minutes
472    /// is normal, and one that built three thousand rows a frame would be a picker
473    /// nobody could open.
474    ///
475    /// It is more than a flat strip would show, and that is the point: rows a quarter
476    /// turn away are compressed into the last pixels at either end, so their flat
477    /// positions are well outside the viewport and the window has to be widened to
478    /// reach them. What it must not do is widen without limit.
479    #[test]
480    fn a_wheel_of_three_thousand_builds_a_dozen() {
481        let built = Rc::new(Cell::new(0));
482        let wheel = wheel(3000, 1500, built.clone());
483        let runtime = Runtime::default();
484        ui_of(&wheel, &runtime);
485        let flat = (VIEWPORT / EXTENT).ceil() as usize + 1;
486        assert!(
487            built.get() > flat,
488            "a wheel shows more rows than a flat strip: {} vs {flat}",
489            built.get()
490        );
491        assert!(
492            built.get() < 3 * flat,
493            "and not without limit: {} of three thousand",
494            built.get()
495        );
496    }
497
498    /// **The rows are not where a flat strip would put them.** The one thing that says
499    /// the geometry reached the paint rather than merely being computed: the rows either
500    /// side of the middle are drawn closer in than their own height, and shorter.
501    #[test]
502    fn the_paint_puts_the_rows_on_the_cylinder() {
503        let wheel = wheel(60, 30, Rc::new(Cell::new(0)));
504        let runtime = Runtime::default();
505        let ui = ui_of(&wheel, &runtime);
506        // Every row's painted band, by the red channel the builder above numbered them
507        // with. Layers are transformed at compositing time, so the rects inside carry
508        // the flat geometry — what is asked here is the transform the layer holds.
509        let layers: Vec<_> = ui
510            .scene()
511            .primitives()
512            .iter()
513            .filter_map(|p| match p {
514                Primitive::Layer {
515                    transform: Some(t), ..
516                } => Some(t.affine),
517                _ => None,
518            })
519            .collect();
520        assert!(
521            layers.len() >= 5,
522            "one layer per row on the cylinder: {}",
523            layers.len()
524        );
525        // `m` is `[a, b, c, d, e, f]`: `a` and `d` are the two scales. The middle row is
526        // the untouched one — at the centre the cylinder is tangent to the screen, so
527        // its transform is the identity to within rounding.
528        let flat = layers
529            .iter()
530            .filter(|m| (m.m[0] - 1.0).abs() < 1e-3 && (m.m[3] - 1.0).abs() < 1e-3)
531            .count();
532        assert_eq!(flat, 1, "exactly one row is face on");
533        // And nothing is stretched: every row is squashed or unchanged, never taller.
534        assert!(
535            layers.iter().all(|m| m.m[3] <= 1.0 + 1e-3),
536            "a cylinder turns rows away from the reader, never towards"
537        );
538    }
539
540    /// **A reader hears which value is chosen, in the caller's words.**
541    ///
542    /// A wheel is a one-dimensional selector rather than a list to walk row by row, so
543    /// it says so as one: the role a platform's own picker reports, the value spelled
544    /// out, and the position in the set as the range. That is also what makes the
545    /// change announced — it is the same path a slider's value travels.
546    #[test]
547    fn the_wheel_says_what_it_is_set_to() {
548        let wheel = wheel(60, 30, Rc::new(Cell::new(0))).label(|i| format!("{i} minutes"));
549        let runtime = Runtime::default();
550        let ui = ui_of(&wheel, &runtime);
551        let (_, _, props) = ui
552            .semantics()
553            .iter()
554            .find(|(_, _, s)| s.role == frus_core::Role::Slider)
555            .expect("the wheel announces itself as a selector");
556        assert_eq!(props.value.as_deref(), Some("30 minutes"));
557        assert_eq!(props.range, Some((0.0, 30.0, 59.0)), "30 of 60");
558
559        // And moving it says something else, which is the whole of "announced as it
560        // changes": the value a reader is given is a function of where the wheel is.
561        let moved = wheel_at(31);
562        let ui = ui_of(&moved, &Runtime::default());
563        let (_, _, props) = ui
564            .semantics()
565            .iter()
566            .find(|(_, _, s)| s.role == frus_core::Role::Slider)
567            .expect("still a selector");
568        assert_eq!(props.value.as_deref(), Some("31 minutes"));
569    }
570
571    /// A wheel with words on it, at a given row.
572    fn wheel_at(selected: usize) -> ListWheel<()> {
573        wheel(60, selected, Rc::new(Cell::new(0))).label(|i| format!("{i} minutes"))
574    }
575
576    /// Without words, a position is still better than silence — and is still not an
577    /// answer to "what is this set to". Said out loud so that nobody reads the fallback
578    /// as good enough.
579    #[test]
580    fn a_wheel_with_no_words_gives_back_a_position() {
581        let wheel = wheel(60, 30, Rc::new(Cell::new(0)));
582        let ui = ui_of(&wheel, &Runtime::default());
583        let (_, _, props) = ui
584            .semantics()
585            .iter()
586            .find(|(_, _, s)| s.role == frus_core::Role::Slider)
587            .expect("a selector");
588        assert_eq!(props.value.as_deref(), Some("31 of 60"));
589    }
590
591    /// The geometry itself, at the three points worth pinning: the middle, a turn, and
592    /// the horizon.
593    #[test]
594    fn the_cylinder_packs_shrinks_and_ends() {
595        let g = WheelGeometry::default();
596        let radius = g.radius(VIEWPORT);
597
598        // Face on: nothing happens at all, which is what makes the chosen row the one
599        // that is drawn as it was written.
600        let middle = g.row(VIEWPORT, 0.0).expect("the middle is on the cylinder");
601        assert_eq!(middle.angle, 0.0);
602        assert_eq!(middle.offset, 0.0);
603        assert!((middle.scale_x - 1.0).abs() < 1e-6);
604        assert!((middle.scale_y - 1.0).abs() < 1e-6);
605        assert!((middle.opacity - 1.0).abs() < 1e-6);
606
607        // A row a third of a radian round: nearer the middle than the flat strip put it,
608        // shorter, narrower and fainter — each of those in the same direction.
609        let flat = radius / 3.0;
610        let turned = g.row(VIEWPORT, flat).expect("still in front");
611        assert!((turned.angle - 1.0 / 3.0).abs() < 1e-5);
612        assert!(
613            turned.offset < flat && turned.offset > 0.0,
614            "packed towards the middle: {} of {flat}",
615            turned.offset
616        );
617        assert!(turned.scale_y < 1.0 && turned.scale_y > 0.9);
618        assert!(turned.scale_x < 1.0);
619        assert!(turned.opacity < 1.0);
620
621        // Symmetric, because a wheel has no top and bottom of its own.
622        let mirrored = g.row(VIEWPORT, -flat).expect("still in front");
623        assert!((mirrored.offset + turned.offset).abs() < 1e-4);
624        assert!((mirrored.scale_y - turned.scale_y).abs() < 1e-6);
625
626        // A quarter turn is the horizon: exactly there and beyond it, a row is a line
627        // seen edge-on and is not drawn.
628        let horizon = radius * std::f32::consts::FRAC_PI_2;
629        assert!(g.row(VIEWPORT, horizon).is_none());
630        assert!(g.row(VIEWPORT, horizon * 1.5).is_none());
631        assert!(g.row(VIEWPORT, horizon * 0.99).is_some());
632    }
633
634    /// **A flatter wheel is a bigger cylinder**, and the numbers have to move that way
635    /// round: it is the one parameter a caller will reach for, and one whose sign is
636    /// backwards is worse than one that does not exist.
637    #[test]
638    fn a_larger_diameter_is_a_flatter_wheel() {
639        let flat_at = |ratio: f32| {
640            WheelGeometry {
641                diameter_ratio: ratio,
642                ..Default::default()
643            }
644            .row(VIEWPORT, 60.0)
645            .expect("in front")
646        };
647        let curved = flat_at(1.07);
648        let flatter = flat_at(6.0);
649        assert!(
650            flatter.angle < curved.angle,
651            "the same row has turned less: {} vs {}",
652            flatter.angle,
653            curved.angle
654        );
655        assert!(
656            flatter.scale_y > curved.scale_y,
657            "so it is squashed less: {} vs {}",
658            flatter.scale_y,
659            curved.scale_y
660        );
661        assert!(
662            flatter.offset > curved.offset,
663            "and packed towards the middle less: {} vs {}",
664            flatter.offset,
665            curved.offset
666        );
667    }
668}