authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 22:04:46-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 04:23:49-07:00
log710848e64a97302d4f0df26f496f6d1d3de7e809
tree2200ad3af046d89fe50684062b3d8cf611685539
parent1bdd0ee601a3ec4c0ebe18ad7a462ae85df968c2
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: list rows squash out and unfold in one motion

Leaving rows fold into the seam their gap closes on while kept rows slide, blending toward the list's colour so folded rows never double up; arriving and reordered rows unfold from their seam in the same motion, and no row's text crosses another's. Assisted-by: claude-opus-5.5 Assisted-by: claude-opus-5

4 files changed, 246 insertions(+), 178 deletions(-)

crates/ui/src/lib.rs+18-5
...@@ -248,6 +248,9 @@ pub struct Spec<'a> {...@@ -248,6 +248,9 @@ pub struct Spec<'a> {
248 pub anchor: Option<Anchor>,248 pub anchor: Option<Anchor>,
249 /// How transparent the box and its contents are, from 0 to 1.249 /// How transparent the box and its contents are, from 0 to 1.
250 pub fade: f32,250 pub fade: f32,
251 /// The colour `fade` blends the box and its contents into instead, so boxes fading
252 /// over one another never add up.
253 pub fade_into: Option<[f32; 4]>,
251}254}
252255
253/// Input the host forwards; positions and wheel distances are logical pixels.256/// Input the host forwards; positions and wheel distances are logical pixels.
...@@ -329,6 +332,7 @@ struct Built {...@@ -329,6 +332,7 @@ struct Built {
329 anchor: Option<Anchor>,332 anchor: Option<Anchor>,
330 /// The opacity of the box and its contents; a popup's rises as it opens.333 /// The opacity of the box and its contents; a popup's rises as it opens.
331 alpha: f32,334 alpha: f32,
335 fade_into: Option<[f32; 4]>,
332 /// Rectangles relative to the box, painted over its fill.336 /// Rectangles relative to the box, painted over its fill.
333 marks: Vec<([f32; 4], [f32; 4])>,337 marks: Vec<([f32; 4], [f32; 4])>,
334 computed: [f32; 2],338 computed: [f32; 2],
...@@ -420,9 +424,15 @@ enum Display {...@@ -420,9 +424,15 @@ enum Display {
420}424}
421425
422impl Display {426impl Display {
423 /// Multiplies the item's opacity by `alpha`.427 /// Multiplies the item's opacity by `alpha`, or blends it that far into `into`.
424 fn fade(&mut self, alpha: f32) {428 fn fade(&mut self, alpha: f32, into: Option<[f32; 4]>) {
425 let fade = |color: &mut [f32; 4]| color[3] *= alpha;429 let fade = |color: &mut [f32; 4]| match into {
430 Some(into) => {
431 let [red, green, blue, _] = mix(into, *color, alpha);
432 *color = [red, green, blue, color[3]];
433 }
434 None => color[3] *= alpha,
435 };
426 match self {436 match self {
427 Display::Rect {437 Display::Rect {
428 fill,438 fill,
...@@ -1098,10 +1108,12 @@ impl Ui {...@@ -1098,10 +1108,12 @@ impl Ui {
1098 if inner_clip != clip {1108 if inner_clip != clip {
1099 self.display.push(Display::Clip(clip));1109 self.display.push(Display::Clip(clip));
1100 }1110 }
1101 let alpha = self.nodes[index].alpha;1111 let Built {
1112 alpha, fade_into, ..
1113 } = self.nodes[index];
1102 if alpha < 1.0 {1114 if alpha < 1.0 {
1103 for item in &mut self.display[start..] {1115 for item in &mut self.display[start..] {
1104 item.fade(alpha);1116 item.fade(alpha, fade_into);
1105 }1117 }
1106 }1118 }
1107 }1119 }
...@@ -1280,6 +1292,7 @@ impl Built {...@@ -1280,6 +1292,7 @@ impl Built {
1280 cursor: spec.cursor,1292 cursor: spec.cursor,
1281 anchor: spec.anchor,1293 anchor: spec.anchor,
1282 alpha: 1.0 - spec.fade,1294 alpha: 1.0 - spec.fade,
1295 fade_into: spec.fade_into,
1283 marks: Vec::new(),1296 marks: Vec::new(),
1284 computed: [0.0; 2],1297 computed: [0.0; 2],
1285 relative: [0.0; 2],1298 relative: [0.0; 2],
crates/ui/src/list.rs+198-151
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1//! A virtualized list of equal rows keyed by their items: only rows in view are built,1//! A virtualized list of equal rows keyed by their items: only rows in view are built,
2//! rows move to where their items go without passing over one another, and the view holds2//! rows slide to where their items go, rows leaving fold into the seam their gap closes on
3//! its place on the selection while items arrive and leave above it.3//! and rows arriving unfold out of it, and the view holds its place on the selection while
4//! items arrive and leave above it.
45
5use crate::{Axis, Event, Flags, HALF_LIFE, Id, Size, Spec, Ui, fill, mix, px, scrollbar};6use crate::{Axis, Event, Flags, HALF_LIFE, Id, Size, Spec, Ui, fill, mix, px, scrollbar};
6use std::collections::{HashMap, HashSet};7use std::collections::{HashMap, HashSet};
...@@ -8,7 +9,7 @@ use winit::keyboard::NamedKey;...@@ -8,7 +9,7 @@ use winit::keyboard::NamedKey;
89
9/// Room beside a scrolling list's rows for its scrollbar.10/// Room beside a scrolling list's rows for its scrollbar.
10pub(crate) const GUTTER: f32 = 12.0;11pub(crate) const GUTTER: f32 = 12.0;
11/// Seconds a row takes to reach its new place: half leaving where it was, half arriving.12/// Seconds rows take to reach their new places.
12const DURATION: f32 = 0.15;13const DURATION: f32 = 0.15;
1314
14/// Items a list shows, in order.15/// Items a list shows, in order.
...@@ -41,8 +42,8 @@ pub struct List<'a, R> {...@@ -41,8 +42,8 @@ pub struct List<'a, R> {
41 pub hover_selects: bool,42 pub hover_selects: bool,
42}43}
4344
44/// A row to build: its item's key, its index while listed or None as it fades out after45/// A row to build: its item's key, its index while listed or None as it folds away after
45/// leaving, and whether it is selected.46/// its item left or moved, and whether it is selected.
46pub struct Row {47pub struct Row {
47 pub key: u64,48 pub key: u64,
48 pub index: Option<usize>,49 pub index: Option<usize>,
...@@ -58,8 +59,6 @@ pub(crate) struct State {...@@ -58,8 +59,6 @@ pub(crate) struct State {
58 shown: Vec<Shown>,59 shown: Vec<Shown>,
59 /// The selection last frame, so a new one scrolls into view.60 /// The selection last frame, so a new one scrolls into view.
60 selected: Option<u64>,61 selected: Option<u64>,
61 /// How far the furthest sliding row was from its place last frame.
62 slack: f64,
63}62}
6463
65#[derive(Clone, Copy)]64#[derive(Clone, Copy)]
...@@ -67,72 +66,58 @@ struct Shown {...@@ -67,72 +66,58 @@ struct Shown {
67 key: u64,66 key: u64,
68 /// Where its row lies, from the top of the view.67 /// Where its row lies, from the top of the view.
69 place: f64,68 place: f64,
69 /// None for a row folding away after its item left or moved.
70 index: Option<usize>,70 index: Option<usize>,
71 motion: Option<Motion>,71 motion: Option<Motion>,
72 /// Where it was drawn from the top of the view, and how strongly.
73 drawn: f64,
74 alpha: f32,
72}75}
7376
74impl Shown {
75 /// Where the row is drawn from the top of the view, and its opacity.
76 fn drawn(&self, reach: f64, slack: f64) -> (f64, f32) {
77 self.motion.map_or((self.place, 1.0), |motion| {
78 let (offset, alpha) = motion.at(reach, slack);
79 (self.place + offset, alpha)
80 })
81 }
82}
83
84/// A row's way to its place after the items change, between offsets from the place.
85#[derive(Clone, Copy)]77#[derive(Clone, Copy)]
86struct Motion {78struct Motion {
87 from: f64,
88 to: f64,
89 /// Its opacity as it set out.
90 alpha: f32,
91 style: Style,
92 elapsed: f32,79 elapsed: f32,
80 way: Way,
93}81}
9482
95#[derive(Clone, Copy, PartialEq)]83#[derive(Clone, Copy)]
96enum Style {84enum Way {
97 /// A move keeping its order with the rows around: waits out those leaving, then slides.85 /// Slides to its place from this offset.
98 Slide,86 Slide(f64),
99 /// Fades out setting off towards the midpoint and in arriving from it, unseen where87 Fold(Fold),
100 /// it passes other rows.
101 Cross,
102 /// Fades in arriving from the middle of the rows entering with it.
103 Enter,
104 /// Fades out setting off towards the middle of the rows leaving with it.
105 Leave,
106}88}
10789
108impl Motion {90/// A row folding into the gap its run leaves between two rows, or unfolding out of the
109 /// Its offset and opacity; beyond `reach` of both ends it cannot be seen, so its text91/// gap its run opens, squeezed among the run and blending into the background as the gap
110 /// never meets a neighbour's, nor arriving while a row sliding is `slack` from its place.92/// narrows.
111 fn at(self, reach: f64, slack: f64) -> (f64, f32) {93#[derive(Clone, Copy)]
112 let t = (self.elapsed / DURATION).min(1.0);94struct Fold {
113 let [first, second] = [(2.0 * t).min(1.0), (2.0 * t - 1.0).max(0.0)];95 /// The gap's edges as it starts and ends, as offsets from the row's place.
114 let out = |x: f32| 1.0 - (1.0 - x).powi(3);96 start: [f64; 2],
115 let inward = |x: f32| x.powi(3);97 end: [f64; 2],
116 let leave = self.alpha * (1.0 - out(first));98 /// The row's position in its run, and the run's length.
117 let (travel, fade) = match self.style {99 at: usize,
118 Style::Slide => (out(second), 1.0),100 of: usize,
119 Style::Cross => (101 /// How strongly it was drawn as it set out.
120 0.5 * inward(first) + 0.5 * out(second),102 alpha: f32,
121 if t < 0.5 { leave } else { inward(second) },103}
122 ),104
123 Style::Enter => (out(second), inward(second)),105impl Fold {
124 Style::Leave => (inward(first), leave),106 /// Where the row is drawn and how strongly, `progress` of the way through, never over
125 };107 /// the `solid` rows drawn at these tops, in order. Its text keeps inside the gap, gone
126 let offset = self.from + (self.to - self.from) * f64::from(travel);108 /// before the gap is too narrow to hold it clear of the rows either side.
127 let near = (offset - self.from).abs().min((offset - self.to).abs());109 fn at(&self, place: f64, progress: f64, height: f64, solid: &[f64]) -> (f64, f32) {
128 let seen = |distance: f64| (1.0 - distance / reach).clamp(0.0, 1.0) as f32;110 let [top, bottom] = [0, 1]
129 let alpha = match self.style {111 .map(|side| place + self.start[side] + (self.end[side] - self.start[side]) * progress);
130 Style::Slide => 1.0,112 let below = solid.partition_point(|y| y + height / 2.0 <= (top + bottom) / 2.0);
131 Style::Enter => fade.min(seen(near)) * seen(slack),113 let top = below
132 Style::Cross if t >= 0.5 => fade.min(seen(near)) * seen(slack),114 .checked_sub(1)
133 _ => fade.min(seen(near)),115 .map_or(top, |above| top.max(solid[above] + height));
134 };116 let bottom = solid.get(below).map_or(bottom, |y| bottom.min(*y));
135 (offset, alpha)117 let share = (bottom - top).max(0.0) / self.of as f64;
118 let y = top + (self.at as f64 + 0.5) * share - height / 2.0;
119 let room = (2.0 * share / height - 1.0).clamp(0.0, 1.0) as f32;
120 (y, room * self.alpha)
136 }121 }
137}122}
138123
...@@ -201,7 +186,7 @@ pub fn list<R: Rows>(...@@ -201,7 +186,7 @@ pub fn list<R: Rows>(
201 }186 }
202 }187 }
203 let mut clicked = None;188 let mut clicked = None;
204 for shown in &state.shown {189 for shown in state.shown.iter().filter(|shown| shown.index.is_some()) {
205 let signal = ui.signal(id.child(shown.key));190 let signal = ui.signal(id.child(shown.key));
206 let Some(index) = rows.find(shown.key).filter(|index| rows.selectable(*index)) else {191 let Some(index) = rows.find(shown.key).filter(|index| rows.selectable(*index)) else {
207 continue;192 continue;
...@@ -263,25 +248,21 @@ pub fn list<R: Rows>(...@@ -263,25 +248,21 @@ pub fn list<R: Rows>(
263 let scroll = state.scroll;248 let scroll = state.scroll;
264 let step = scroll - anchored;249 let step = scroll - anchored;
265250
266 let reach = height / 6.0;
267 let dt = ui.dt;251 let dt = ui.dt;
268 let tick = |motion: Motion| {252 let tick = |motion: Motion| {
269 let elapsed = motion.elapsed + dt;253 let elapsed = motion.elapsed + dt;
270 (elapsed < DURATION).then_some(Motion { elapsed, ..motion })254 (elapsed < DURATION).then_some(Motion { elapsed, ..motion })
271 };255 };
272 // Rows built last frame, drawn in the view as anchored.256 let progress = |motion: &Motion| 1.0 - (1.0 - f64::from(motion.elapsed / DURATION)).powi(3);
273 let before: HashMap<u64, Shown> = state257 let listed: Vec<Shown> = state
274 .shown
275 .iter()
276 .map(|shown| (shown.key, *shown))
277 .collect();
278 let listed: Vec<&Shown> = state
279 .shown258 .shown
280 .iter()259 .iter()
281 .filter(|shown| shown.index.is_some())260 .filter(|shown| shown.index.is_some())
261 .copied()
282 .collect();262 .collect();
283 // The most rows staying listed that keep their order slide; the rest would pass over263 let before: HashMap<u64, Shown> = listed.iter().map(|shown| (shown.key, *shown)).collect();
284 // them, so cross unseen.264 // The most rows that stay in their order slide; the rest would pass over them, so fold
265 // away and unfold where they go, as do rows moving while partly folded.
285 let order: Vec<(u64, usize)> = listed266 let order: Vec<(u64, usize)> = listed
286 .iter()267 .iter()
287 .filter_map(|shown| Some((shown.key, rows.find(shown.key)?)))268 .filter_map(|shown| Some((shown.key, rows.find(shown.key)?)))
...@@ -299,81 +280,91 @@ pub fn list<R: Rows>(...@@ -299,81 +280,91 @@ pub fn list<R: Rows>(
299 }280 }
300 let kept: HashSet<u64> = std::iter::successors(tails.last().copied(), |at| previous[*at])281 let kept: HashSet<u64> = std::iter::successors(tails.last().copied(), |at| previous[*at])
301 .map(|at| order[at].0)282 .map(|at| order[at].0)
283 .filter(|key| before[key].alpha >= 1.0)
302 .collect();284 .collect();
303 let arrive = |index: usize| {285
286 let settled = |index: usize| {
304 let key = rows.key(index);287 let key = rows.key(index);
305 let was = top(index) - anchored;288 let was = top(index) - anchored;
306 let motion = before.get(&key).and_then(|old| {289 let motion = before.get(&key).and_then(|old| {
307 if old.index.is_some() && (old.place - was).abs() < 0.5 {290 if (old.place - was).abs() < 0.5 {
308 return old.motion.and_then(tick);291 old.motion.and_then(tick)
309 }
310 let (drawn, alpha) = old.drawn(reach, state.slack);
311 let from = drawn - was;
312 let style = if alpha < 1.0 || !kept.contains(&key) {
313 Style::Cross
314 } else {292 } else {
315 Style::Slide293 tick(Motion {
316 };294 elapsed: 0.0,
317 tick(Motion {295 way: Way::Slide(old.drawn - was),
318 from,296 })
319 to: 0.0,297 }
320 alpha,
321 style,
322 elapsed: 0.0,
323 })
324 });298 });
325 Shown {299 Shown {
326 key,300 key,
327 place: top(index) - scroll,301 place: top(index) - scroll,
328 index: Some(index),302 index: Some(index),
329 motion,303 motion,
304 drawn: 0.0,
305 alpha: 1.0,
330 }306 }
331 };307 };
332 // Rows newly where the view already was, rather than scrolled into it, enter.308 // Rows arriving where the view already was, rather than scrolled into it, unfold.
333 let entering = |index: usize| {309 let arriving = |index: usize| {
310 let key = rows.key(index);
334 let was = top(index) - anchored;311 let was = top(index) - anchored;
335 !fresh && !before.contains_key(&rows.key(index)) && was + height > 0.0 && was < view312 match before.get(&key) {
313 Some(old) => (old.place - was).abs() >= 0.5 && !kept.contains(&key),
314 None => !fresh && was + height > 0.0 && was < view,
315 }
336 };316 };
337 let in_view = row_at(scroll)..(row_at(scroll + view) + 1).min(len);317 let in_view = row_at(scroll)..(row_at(scroll + view) + 1).min(len);
338 let mut shown = Vec::new();318 let mut shown = Vec::new();
339 let mut index = in_view.start;319 let mut index = in_view.start;
340 while index < in_view.end {320 while index < in_view.end {
341 if !entering(index) {321 if !arriving(index) {
342 shown.push(arrive(index));322 shown.push(settled(index));
343 index += 1;323 index += 1;
344 continue;324 continue;
345 }325 }
346 let end = (index..in_view.end)326 let end = (index..in_view.end)
347 .find(|index| !entering(*index))327 .find(|index| !arriving(*index))
348 .unwrap_or(in_view.end);328 .unwrap_or(in_view.end);
349 let middle = (top(index) + top(end - 1)) / 2.0;329 let [above, below] = [index.checked_sub(1), (end < len).then_some(end)]
350 shown.extend((index..end).map(|index| Shown {330 .map(|side| side.map(|side| rows.key(side)));
351 key: rows.key(index),331 let [first, last] = [top(index), top(end - 1) + height].map(|y| y - anchored);
352 place: top(index) - scroll,332 // The seam opens where the rows either side were drawn, or mid-run with neither.
353 index: Some(index),333 let seam = above
354 motion: tick(Motion {334 .and_then(|key| Some(before.get(&key)?.drawn + height))
355 from: middle - top(index),335 .or_else(|| below.and_then(|key| Some(before.get(&key)?.drawn)))
356 to: 0.0,336 .unwrap_or((first + last) / 2.0);
357 alpha: 0.0,337 shown.extend((index..end).map(|at| {
358 style: Style::Enter,338 let was = top(at) - anchored;
359 elapsed: 0.0,339 Shown {
360 }),340 motion: tick(Motion {
341 elapsed: 0.0,
342 way: Way::Fold(Fold {
343 start: [seam - was; 2],
344 end: [first - was, last - was],
345 at: at - index,
346 of: end - index,
347 alpha: 1.0,
348 }),
349 }),
350 ..settled(at)
351 }
361 }));352 }));
362 index = end;353 index = end;
363 }354 }
364 for old in &listed {355 for old in &listed {
365 if let Some(index) = rows.find(old.key).filter(|index| !in_view.contains(index)) {356 if let Some(index) = rows
366 let moving = arrive(index);357 .find(old.key)
367 let drawn = moving.drawn(reach, state.slack).0;358 .filter(|index| !in_view.contains(index) && kept.contains(&old.key))
368 if drawn + height > 0.0 && drawn < view {359 {
369 shown.push(moving);360 shown.push(settled(index));
370 }
371 }361 }
372 }362 }
373 let mut leaving: Vec<Shown> = state363 // Rows already folding away carry on; runs of rows gone or moving out of order start.
364 let mut folding: Vec<Shown> = state
374 .shown365 .shown
375 .iter()366 .iter()
376 .filter(|shown| shown.index.is_none() && rows.find(shown.key).is_none())367 .filter(|shown| shown.index.is_none())
377 .filter_map(|shown| {368 .filter_map(|shown| {
378 Some(Shown {369 Some(Shown {
379 place: shown.place - step,370 place: shown.place - step,
...@@ -382,40 +373,94 @@ pub fn list<R: Rows>(...@@ -382,40 +373,94 @@ pub fn list<R: Rows>(
382 })373 })
383 })374 })
384 .collect();375 .collect();
385 let gone = |shown: &&Shown| rows.find(shown.key).is_none();376 let staying = |shown: &Shown| {
386 for run in listed.chunk_by(|a, b| gone(a) == gone(b)) {377 kept.contains(&shown.key)
387 if !gone(&run[0]) {378 || rows
379 .find(shown.key)
380 .is_some_and(|index| (top(index) - anchored - shown.place).abs() < 0.5)
381 };
382 let mut at = 0;
383 for run in listed.chunk_by(|a, b| staying(a) == staying(b)) {
384 let start = at;
385 at += run.len();
386 if staying(&run[0]) {
388 continue;387 continue;
389 }388 }
390 let drawn: Vec<_> = run389 let above = start.checked_sub(1).map(|side| listed[side].key);
391 .iter()390 let below = listed.get(at).map(|side| side.key);
392 .map(|shown| shown.drawn(reach, state.slack))391 let [first, last] = [run[0].drawn, run[run.len() - 1].drawn + height];
393 .collect();392 // The seam the gap closes on: where the row below or above it now lies.
394 let middle = (drawn[0].0 + drawn[drawn.len() - 1].0) / 2.0;393 let seam = below
395 leaving.extend(run.iter().zip(drawn).map(|(shown, (y, alpha))| Shown {394 .and_then(|key| Some(top(rows.find(key)?) - anchored))
396 key: shown.key,395 .or_else(|| above.and_then(|key| Some(top(rows.find(key)?) + height - anchored)))
397 place: y - step,396 .unwrap_or(first);
397 folding.extend(run.iter().enumerate().map(|(at, old)| Shown {
398 key: old.key,
399 place: old.drawn - step,
398 index: None,400 index: None,
399 motion: tick(Motion {401 motion: tick(Motion {
400 from: 0.0,
401 to: middle - y,
402 alpha,
403 style: Style::Leave,
404 elapsed: 0.0,402 elapsed: 0.0,
403 way: Way::Fold(Fold {
404 start: [first - old.drawn, last - old.drawn],
405 end: [seam - old.drawn; 2],
406 at,
407 of: run.len(),
408 alpha: old.alpha,
409 }),
405 }),410 }),
411 drawn: 0.0,
412 alpha: 0.0,
406 }));413 }));
407 }414 }
408 shown.sort_by(|a, b| a.place.total_cmp(&b.place));415
409 let slack = shown416 // Rows sliding or still are drawn first, as folds are bounded by them.
417 for shown in &mut shown {
418 if let Some(Motion {
419 way: Way::Slide(from),
420 ..
421 }) = shown.motion
422 {
423 shown.drawn = shown.place + from * (1.0 - progress(&shown.motion.unwrap()));
424 } else {
425 shown.drawn = shown.place;
426 }
427 }
428 let mut solid: Vec<f64> = shown
410 .iter()429 .iter()
411 .filter_map(|shown| shown.motion.filter(|motion| motion.style == Style::Slide))430 .filter(|shown| {
412 .map(|motion| motion.at(reach, 0.0).0.abs())431 !matches!(
413 .fold(0.0, f64::max);432 shown.motion,
433 Some(Motion {
434 way: Way::Fold(_),
435 ..
436 })
437 )
438 })
439 .map(|shown| shown.drawn)
440 .collect();
441 solid.sort_by(f64::total_cmp);
442 for shown in shown.iter_mut().chain(&mut folding) {
443 if let Some(
444 motion @ Motion {
445 way: Way::Fold(fold),
446 ..
447 },
448 ) = shown.motion
449 {
450 (shown.drawn, shown.alpha) = fold.at(shown.place, progress(&motion), height, &solid);
451 }
452 }
453 shown.retain(|shown| {
454 shown.drawn + height > 0.0 && shown.drawn < view
455 || shown.index.is_some_and(|index| in_view.contains(&index))
456 });
457 shown.sort_by(|a, b| a.place.total_cmp(&b.place));
414 ui.animating |= state.scroll != state.target458 ui.animating |= state.scroll != state.target
415 || !leaving.is_empty()459 || !folding.is_empty()
416 || shown.iter().any(|shown| shown.motion.is_some());460 || shown.iter().any(|shown| shown.motion.is_some());
417461
418 let theme = ui.theme.clone();462 let theme = ui.theme.clone();
463 let background = spec.fill.unwrap_or(theme.base);
419 ui.open_as(464 ui.open_as(
420 id,465 id,
421 Spec {466 Spec {
...@@ -428,22 +473,25 @@ pub fn list<R: Rows>(...@@ -428,22 +473,25 @@ pub fn list<R: Rows>(
428 Some(rect) if most > 0.0 => px(rect[2] - rect[0] - GUTTER),473 Some(rect) if most > 0.0 => px(rect[2] - rect[0] - GUTTER),
429 _ => fill(),474 _ => fill(),
430 };475 };
431 for shown in leaving.iter().chain(&shown) {476 // Rows folding away paint beneath the rest, and blend into the list rather than fade,
477 // so those squeezed together never add up.
478 for shown in folding.iter().chain(&shown) {
432 let flags = match shown.index {479 let flags = match shown.index {
433 Some(index) if rows.selectable(index) => Flags::FLOAT | Flags::CLICKABLE,480 Some(index) if rows.selectable(index) => Flags::FLOAT | Flags::CLICKABLE,
434 _ => Flags::FLOAT,481 _ => Flags::FLOAT,
435 };482 };
436 let (y, alpha) = shown.drawn(reach, slack);483 let row_spec = Spec {
437 ui.open(484 flags,
438 shown.key,485 size: [width, px(row)],
439 Spec {486 position: [0.0, shown.drawn as f32],
440 flags,487 fade: 1.0 - shown.alpha,
441 size: [width, px(row)],488 fade_into: Some(background),
442 position: [0.0, y as f32],489 ..Spec::default()
443 fade: 1.0 - alpha,490 };
444 ..Spec::default()491 match shown.index {
445 },492 Some(_) => ui.open(shown.key, row_spec),
446 );493 None => ui.open(("folding", shown.key), row_spec),
494 };
447 build(495 build(
448 ui,496 ui,
449 Row {497 Row {
...@@ -468,10 +516,9 @@ pub fn list<R: Rows>(...@@ -468,10 +516,9 @@ pub fn list<R: Rows>(
468 state.target = state.scroll;516 state.target = state.scroll;
469 }517 }
470 ui.close();518 ui.close();
471 shown.extend(leaving);519 shown.extend(folding);
472 shown.sort_by(|a, b| a.place.total_cmp(&b.place));520 shown.sort_by(|a, b| a.place.total_cmp(&b.place));
473 state.shown = shown;521 state.shown = shown;
474 state.slack = slack;
475 ui.lists.insert(id, state);522 ui.lists.insert(id, state);
476 clicked523 clicked
477}524}
crates/ui/src/popup.rs+1
...@@ -195,6 +195,7 @@ fn choose(...@@ -195,6 +195,7 @@ fn choose(
195 id.child("rows"),195 id.child("rows"),
196 Spec {196 Spec {
197 size: [fill(), px(view)],197 size: [fill(), px(view)],
198 fill: Some(theme.popup),
198 ..Spec::default()199 ..Spec::default()
199 },200 },
200 list,201 list,
crates/ui/src/tests.rs+29-22
...@@ -1027,45 +1027,52 @@ fn alpha(ui: &Ui, key: u64) -> Option<f32> {...@@ -1027,45 +1027,52 @@ fn alpha(ui: &Ui, key: u64) -> Option<f32> {
1027 .map(|node| node.alpha)1027 .map(|node| node.alpha)
1028}1028}
10291029
1030fn folding(ui: &Ui, key: u64) -> Option<f32> {
1031 let id = list_id().child(("folding", key));
1032 ui.nodes
1033 .iter()
1034 .find(|node| node.id == id)
1035 .map(|node| node.alpha)
1036}
1037
1030#[test]1038#[test]
1031fn rows_keeping_their_order_slide_and_the_rest_fade_through() {1039fn rows_slide_while_the_rest_fold_away_and_unfold_at_once() {
1032 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);1040 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1033 let mut selected = None;1041 let mut selected = None;
1034 let rows = Keyed::new(0..20);1042 let rows = Keyed::new(0..20);
1035 settle_list(&mut ui, &rows, &mut selected);1043 settle_list(&mut ui, &rows, &mut selected);
1036 let rows = Keyed::new([5, 0, 1, 2, 3, 100, 6, 7, 8, 9, 10, 11]);1044 let rows = Keyed::new([5, 0, 1, 2, 3, 100, 6, 7, 8, 9, 10, 11]);
1037 list_frame(&mut ui, &rows, &mut selected, &[]);1045 list_frame(&mut ui, &rows, &mut selected, &[]);
1038 let crossing = row_top(&ui, 5).unwrap();
1039 assert!(crossing < 5.0 * LIST_ROW && crossing > 4.5 * LIST_ROW);
1040 assert!(alpha(&ui, 5).unwrap() < 1.0, "fading as it sets off");
1041 assert_eq!(row_top(&ui, 0), Some(0.0), "sliding waits for rows leaving");
1042 assert!(alpha(&ui, 4).unwrap() < 1.0);
1043 assert_eq!(alpha(&ui, 100), Some(0.0), "entering waits too");
1044 for _ in 0..4 {
1045 list_frame(&mut ui, &rows, &mut selected, &[]);
1046 }
1047 assert_eq!(alpha(&ui, 5), Some(0.0), "unseen half-way");
1048 assert_eq!(alpha(&ui, 4), Some(0.0));
1049 let sliding = row_top(&ui, 0).unwrap();1046 let sliding = row_top(&ui, 0).unwrap();
1050 assert!(sliding > 0.0 && sliding < LIST_ROW);1047 assert!(
1051 assert_eq!(1048 sliding > 0.0 && sliding < LIST_ROW,
1052 alpha(&ui, 100),1049 "sliding at once: {sliding}"
1053 Some(0.0),
1054 "entering waits for rows sliding"
1055 );1050 );
1056 for _ in 0..5 {1051 for key in [4, 5] {
1052 let alpha = folding(&ui, key).unwrap();
1053 assert!(alpha > 0.0 && alpha < 1.0, "{key} folds where it was");
1054 }
1055 assert!(row_top(&ui, 5).unwrap() < 0.0, "out of the seam at the top");
1056 list_frame(&mut ui, &rows, &mut selected, &[]);
1057 list_frame(&mut ui, &rows, &mut selected, &[]);
1058 for key in [5, 100] {
1059 let alpha = alpha(&ui, key).unwrap();
1060 assert!(alpha > 0.0 && alpha < 1.0, "{key} unfolds where it goes");
1061 }
1062 for _ in 0..7 {
1057 list_frame(&mut ui, &rows, &mut selected, &[]);1063 list_frame(&mut ui, &rows, &mut selected, &[]);
1058 }1064 }
1059 assert_eq!(row_top(&ui, 5), Some(0.0), "there in about 150 ms");1065 assert_eq!(row_top(&ui, 5), Some(0.0), "there in about 150 ms");
1060 assert_eq!(row_top(&ui, 0), Some(LIST_ROW));1066 assert_eq!(row_top(&ui, 0), Some(LIST_ROW));
1067 assert_eq!(row_top(&ui, 100), Some(5.0 * LIST_ROW));
1061 assert_eq!(alpha(&ui, 100), Some(1.0));1068 assert_eq!(alpha(&ui, 100), Some(1.0));
1062 assert_eq!(alpha(&ui, 4), None);1069 assert_eq!((folding(&ui, 4), folding(&ui, 5)), (None, None));
1063 list_frame(&mut ui, &rows, &mut selected, &[]);
1064 assert!(!ui.wants_frame());1070 assert!(!ui.wants_frame());
1065}1071}
10661072
1067/// Two rows whose text is drawn over each other: their labels, centred in each row, meet1073/// Two rows whose text is drawn over each other: their labels, centred in each row, meet
1068/// within the view while both can be seen.1074/// within the view while both can be seen and one is fully drawn. Rows folding together,
1075/// both blended into the background, may meet.
1069fn text_overlaps(ui: &mut Ui) -> Option<String> {1076fn text_overlaps(ui: &mut Ui) -> Option<String> {
1070 let text = ui.measure("Ag")[1];1077 let text = ui.measure("Ag")[1];
1071 let list = ui.nodes.iter().position(|node| node.id == list_id())?;1078 let list = ui.nodes.iter().position(|node| node.id == list_id())?;
...@@ -1086,7 +1093,7 @@ fn text_overlaps(ui: &mut Ui) -> Option<String> {...@@ -1086,7 +1093,7 @@ fn text_overlaps(ui: &mut Ui) -> Option<String> {
1086 rows.iter().enumerate().find_map(|(at, a)| {1093 rows.iter().enumerate().find_map(|(at, a)| {
1087 rows[at + 1..]1094 rows[at + 1..]
1088 .iter()1095 .iter()
1089 .find(|b| a.0 < b.1 && b.0 < a.1)1096 .find(|b| a.0 < b.1 && b.0 < a.1 && a.2.max(b.2) >= 1.0)
1090 .map(|b| format!("{a:?} over {b:?}"))1097 .map(|b| format!("{a:?} over {b:?}"))
1091 })1098 })
1092}1099}