1pub const EDGE_SCALE: f32 = 0.7;
23pub const EDGE_ALPHA: f32 = 0.5;
24pub const MIN_ELEMENT_HEIGHT: f32 = 0.2;
28pub const MAX_ELEMENT_HEIGHT: f32 = 0.6;
29pub const MIN_TRANSITION_AREA: f32 = 0.35;
30pub const MAX_TRANSITION_AREA: f32 = 0.55;
31
32#[derive(Clone, Copy, Debug, PartialEq)]
34pub struct ScaleAlpha {
35 pub scale: f32,
36 pub alpha: f32,
37}
38
39impl ScaleAlpha {
40 pub const UNCHANGED: Self = Self {
42 scale: 1.0,
43 alpha: 1.0,
44 };
45}
46
47pub fn scale_and_alpha(viewport: f32, top: f32, bottom: f32) -> Option<ScaleAlpha> {
55 if !viewport.is_finite() || !top.is_finite() || !bottom.is_finite() || bottom < top {
56 return None;
57 }
58 if viewport <= 0.0 {
59 return Some(ScaleAlpha::UNCHANGED);
60 }
61 let edge = (viewport - top).min(bottom) / viewport;
63 let size_ratio = inverse_lerp(
64 MIN_ELEMENT_HEIGHT,
65 MAX_ELEMENT_HEIGHT,
66 (bottom - top) / viewport,
67 );
68 let line = MIN_TRANSITION_AREA + (MAX_TRANSITION_AREA - MIN_TRANSITION_AREA) * size_ratio;
69 if edge >= line || line <= 0.0 {
70 return Some(ScaleAlpha::UNCHANGED);
71 }
72 let progress = ease(1.0 - edge / line);
73 Some(ScaleAlpha {
74 scale: 1.0 + (EDGE_SCALE - 1.0) * progress,
75 alpha: 1.0 + (EDGE_ALPHA - 1.0) * progress,
76 })
77}
78
79#[derive(Clone, Copy, Debug, PartialEq)]
81pub struct PlacedRow {
82 pub top: f32,
84 pub height: f32,
86 pub scale: f32,
87 pub alpha: f32,
88}
89
90pub fn place_row(viewport: f32, top: f32, height: f32, density: f32) -> Option<PlacedRow> {
106 if !height.is_finite() || height < 0.0 || !density.is_finite() {
107 return None;
108 }
109 if density <= 0.0 {
110 let transform = scale_and_alpha(viewport, top, top + height)?;
111 return Some(PlacedRow {
112 top,
113 height: height * transform.scale,
114 scale: transform.scale,
115 alpha: transform.alpha,
116 });
117 }
118 let viewport_px = (viewport * density).round();
119 let top_px = (top * density).round();
120 let height_px = (height * density).round();
121 let transform = scale_and_alpha(viewport_px, top_px, top_px + height_px)?;
122 let scaled_px = (height_px * transform.scale).round();
123 let above = top_px + top_px + height_px < viewport_px;
126 let pinned = if above {
127 top_px + height_px - scaled_px
128 } else {
129 top_px
130 };
131 Some(PlacedRow {
132 top: (pinned + odd_pixel(height_px) - odd_pixel(scaled_px)) / density,
133 height: height_px * transform.scale / density,
134 scale: transform.scale,
135 alpha: transform.alpha,
136 })
137}
138
139fn odd_pixel(pixels: f32) -> f32 {
142 let half = pixels * 0.5;
143 half - half.floor()
144}
145
146fn inverse_lerp(start: f32, stop: f32, value: f32) -> f32 {
147 ((value - start) / (stop - start)).clamp(0.0, 1.0)
148}
149
150fn ease(x: f32) -> f32 {
156 let x = x.clamp(0.0, 1.0);
157 let mut low = 0.0f32;
158 let mut high = 1.0f32;
159 let mut t = x;
160 for _ in 0..12 {
161 let value = bezier(t, 0.3, 0.7);
162 if value < x {
163 low = t;
164 } else {
165 high = t;
166 }
167 t = (low + high) * 0.5;
168 }
169 bezier(t, 0.0, 1.0)
170}
171
172fn bezier(t: f32, first: f32, second: f32) -> f32 {
173 let inverse = 1.0 - t;
174 3.0 * inverse * inverse * t * first + 3.0 * inverse * t * t * second + t * t * t
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 const VIEWPORT: f32 = 227.0;
182
183 #[test]
184 fn a_row_in_the_middle_is_left_alone() {
185 let middle = scale_and_alpha(VIEWPORT, VIEWPORT * 0.45, VIEWPORT * 0.55).unwrap();
186 assert_eq!(middle, ScaleAlpha::UNCHANGED);
187 }
188
189 #[test]
190 fn a_row_at_the_edge_is_shrunk_and_faded_together() {
191 let edge = scale_and_alpha(VIEWPORT, 0.0, 20.0).unwrap();
192 assert!(edge.scale < 1.0 && edge.scale >= EDGE_SCALE, "{edge:?}");
193 assert!(edge.alpha < 1.0 && edge.alpha >= EDGE_ALPHA, "{edge:?}");
194 let top = scale_and_alpha(VIEWPORT, 0.0, 0.0).unwrap();
197 assert!((top.scale - EDGE_SCALE).abs() < 1e-3, "{top:?}");
198 assert!((top.alpha - EDGE_ALPHA).abs() < 1e-3, "{top:?}");
199 }
200
201 #[test]
202 fn the_two_edges_treat_a_row_the_same() {
203 let height = 40.0;
204 let near_top = scale_and_alpha(VIEWPORT, 8.0, 8.0 + height).unwrap();
205 let near_bottom =
206 scale_and_alpha(VIEWPORT, VIEWPORT - 8.0 - height, VIEWPORT - 8.0).unwrap();
207 assert!((near_top.scale - near_bottom.scale).abs() < 1e-5);
208 assert!((near_top.alpha - near_bottom.alpha).abs() < 1e-5);
209 }
210
211 #[test]
212 fn a_taller_row_starts_shrinking_further_from_the_edge() {
213 let top = VIEWPORT - 10.0;
219 let short = scale_and_alpha(VIEWPORT, top, top + VIEWPORT * 0.2).unwrap();
220 let tall = scale_and_alpha(VIEWPORT, top, top + VIEWPORT * 0.62).unwrap();
221 assert!(tall.scale < short.scale, "short {short:?} tall {tall:?}");
222 }
223
224 #[test]
225 fn a_row_is_placed_from_the_full_heights_above_it_not_the_scaled_ones() {
226 let first = place_row(VIEWPORT, 0.0, 50.0, 2.0).unwrap();
229 let second = place_row(VIEWPORT, 50.0, 50.0, 2.0).unwrap();
230 assert!(first.scale < 1.0, "the first row is at the edge: {first:?}");
231 assert!(second.top >= 49.0, "{second:?}");
233 }
234
235 #[test]
236 fn a_row_above_the_centre_line_keeps_its_bottom_edge() {
237 let above = place_row(VIEWPORT, 4.0, 50.0, 2.0).unwrap();
240 assert!(above.scale < 1.0, "{above:?}");
241 assert!(
242 above.top > 4.0,
243 "shrinking should pull the top down: {above:?}"
244 );
245
246 let below = place_row(VIEWPORT, VIEWPORT - 54.0, 50.0, 2.0).unwrap();
247 assert!(below.scale < 1.0, "{below:?}");
248 assert!(
249 (below.top - (VIEWPORT - 54.0)).abs() < 0.6,
250 "the top is pinned below the line: {below:?}"
251 );
252 }
253
254 #[test]
255 fn an_odd_pixel_height_carries_the_half_pixel_composes_integer_halving_leaves() {
256 assert_eq!(odd_pixel(50.0), 0.0);
259 assert_eq!(odd_pixel(51.0), 0.5);
260 let odd = place_row(VIEWPORT, 3.0, 25.5, 2.0).unwrap();
261 assert!(odd.scale < 1.0, "needs to be in the scaled band: {odd:?}");
262 }
263
264 #[test]
265 fn a_density_of_zero_falls_back_to_continuous_placement_instead_of_dividing_by_it() {
266 let placed = place_row(VIEWPORT, 10.0, 50.0, 0.0).unwrap();
267 assert!(
268 placed.top.is_finite() && placed.height.is_finite(),
269 "{placed:?}"
270 );
271 assert_eq!(placed.top, 10.0);
272 let negative = place_row(VIEWPORT, 10.0, 50.0, -2.0).unwrap();
273 assert_eq!(negative, placed, "a nonsense density is not a crash");
274 }
275
276 #[test]
277 fn an_empty_viewport_leaves_everything_alone_rather_than_dividing_by_it() {
278 assert_eq!(scale_and_alpha(0.0, 0.0, 10.0), Some(ScaleAlpha::UNCHANGED));
279 assert_eq!(
280 scale_and_alpha(-5.0, 0.0, 10.0),
281 Some(ScaleAlpha::UNCHANGED)
282 );
283 }
284
285 #[test]
286 fn invalid_geometry_is_rejected_instead_of_producing_nan() {
287 assert_eq!(scale_and_alpha(f32::NAN, 0.0, 10.0), None);
288 assert_eq!(scale_and_alpha(VIEWPORT, 10.0, 9.0), None);
289 assert_eq!(place_row(VIEWPORT, 0.0, -1.0, 2.0), None);
290 assert_eq!(place_row(VIEWPORT, 0.0, 10.0, f32::INFINITY), None);
291 }
292
293 #[test]
294 fn the_easing_is_monotonic_and_spans_the_whole_range() {
295 assert!((ease(0.0) - 0.0).abs() < 1e-3, "{}", ease(0.0));
296 assert!((ease(1.0) - 1.0).abs() < 1e-3, "{}", ease(1.0));
297 let mut previous = -1.0;
298 for step in 0..=20 {
299 let value = ease(step as f32 / 20.0);
300 assert!(value >= previous - 1e-4, "not monotonic at {step}");
301 previous = value;
302 }
303 }
304
305 #[test]
306 fn the_easing_matches_the_current_wear_compose_curve() {
307 assert!((ease(0.25) - 0.166_779).abs() < 1e-3, "{}", ease(0.25));
308 }
309}