From 710848e64a97302d4f0df26f496f6d1d3de7e809 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 25 Sep 2026 22:04:46 -0700 Subject: [PATCH] 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 --- crates/ui/src/lib.rs | 23 ++- crates/ui/src/list.rs | 349 +++++++++++++++++++++++------------------ crates/ui/src/popup.rs | 1 + crates/ui/src/tests.rs | 51 +++--- 4 files changed, 246 insertions(+), 178 deletions(-) diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 485791734493ffb19e9cdfdb1e896f1cb482e8f3..4054c623efac6efdea5ed0b312fdcdcfee36b77e 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -248,6 +248,9 @@ pub struct Spec<'a> { pub anchor: Option, /// How transparent the box and its contents are, from 0 to 1. pub fade: f32, + /// The colour `fade` blends the box and its contents into instead, so boxes fading + /// over one another never add up. + pub fade_into: Option<[f32; 4]>, } /// Input the host forwards; positions and wheel distances are logical pixels. @@ -329,6 +332,7 @@ struct Built { anchor: Option, /// The opacity of the box and its contents; a popup's rises as it opens. alpha: f32, + fade_into: Option<[f32; 4]>, /// Rectangles relative to the box, painted over its fill. marks: Vec<([f32; 4], [f32; 4])>, computed: [f32; 2], @@ -420,9 +424,15 @@ enum Display { } impl Display { - /// Multiplies the item's opacity by `alpha`. - fn fade(&mut self, alpha: f32) { - let fade = |color: &mut [f32; 4]| color[3] *= alpha; + /// Multiplies the item's opacity by `alpha`, or blends it that far into `into`. + fn fade(&mut self, alpha: f32, into: Option<[f32; 4]>) { + let fade = |color: &mut [f32; 4]| match into { + Some(into) => { + let [red, green, blue, _] = mix(into, *color, alpha); + *color = [red, green, blue, color[3]]; + } + None => color[3] *= alpha, + }; match self { Display::Rect { fill, @@ -1098,10 +1108,12 @@ impl Ui { if inner_clip != clip { self.display.push(Display::Clip(clip)); } - let alpha = self.nodes[index].alpha; + let Built { + alpha, fade_into, .. + } = self.nodes[index]; if alpha < 1.0 { for item in &mut self.display[start..] { - item.fade(alpha); + item.fade(alpha, fade_into); } } } @@ -1280,6 +1292,7 @@ impl Built { cursor: spec.cursor, anchor: spec.anchor, alpha: 1.0 - spec.fade, + fade_into: spec.fade_into, marks: Vec::new(), computed: [0.0; 2], relative: [0.0; 2], diff --git a/crates/ui/src/list.rs b/crates/ui/src/list.rs index 98210f68dea38acc18d0a12eb1485e58c01419ed..d527f4e060a1aef949e15fc8cbc05bb0ae83ac2d 100644 --- a/crates/ui/src/list.rs +++ b/crates/ui/src/list.rs @@ -1,6 +1,7 @@ //! A virtualized list of equal rows keyed by their items: only rows in view are built, -//! rows move to where their items go without passing over one another, and the view holds -//! its place on the selection while items arrive and leave above it. +//! rows slide to where their items go, rows leaving fold into the seam their gap closes on +//! and rows arriving unfold out of it, and the view holds its place on the selection while +//! items arrive and leave above it. use crate::{Axis, Event, Flags, HALF_LIFE, Id, Size, Spec, Ui, fill, mix, px, scrollbar}; use std::collections::{HashMap, HashSet}; @@ -8,7 +9,7 @@ use winit::keyboard::NamedKey; /// Room beside a scrolling list's rows for its scrollbar. pub(crate) const GUTTER: f32 = 12.0; -/// Seconds a row takes to reach its new place: half leaving where it was, half arriving. +/// Seconds rows take to reach their new places. const DURATION: f32 = 0.15; /// Items a list shows, in order. @@ -41,8 +42,8 @@ pub struct List<'a, R> { pub hover_selects: bool, } -/// A row to build: its item's key, its index while listed or None as it fades out after -/// leaving, and whether it is selected. +/// A row to build: its item's key, its index while listed or None as it folds away after +/// its item left or moved, and whether it is selected. pub struct Row { pub key: u64, pub index: Option, @@ -58,8 +59,6 @@ pub(crate) struct State { shown: Vec, /// The selection last frame, so a new one scrolls into view. selected: Option, - /// How far the furthest sliding row was from its place last frame. - slack: f64, } #[derive(Clone, Copy)] @@ -67,72 +66,58 @@ struct Shown { key: u64, /// Where its row lies, from the top of the view. place: f64, + /// None for a row folding away after its item left or moved. index: Option, motion: Option, + /// Where it was drawn from the top of the view, and how strongly. + drawn: f64, + alpha: f32, } -impl Shown { - /// Where the row is drawn from the top of the view, and its opacity. - fn drawn(&self, reach: f64, slack: f64) -> (f64, f32) { - self.motion.map_or((self.place, 1.0), |motion| { - let (offset, alpha) = motion.at(reach, slack); - (self.place + offset, alpha) - }) - } -} - -/// A row's way to its place after the items change, between offsets from the place. #[derive(Clone, Copy)] struct Motion { - from: f64, - to: f64, - /// Its opacity as it set out. - alpha: f32, - style: Style, elapsed: f32, + way: Way, } -#[derive(Clone, Copy, PartialEq)] -enum Style { - /// A move keeping its order with the rows around: waits out those leaving, then slides. - Slide, - /// Fades out setting off towards the midpoint and in arriving from it, unseen where - /// it passes other rows. - Cross, - /// Fades in arriving from the middle of the rows entering with it. - Enter, - /// Fades out setting off towards the middle of the rows leaving with it. - Leave, +#[derive(Clone, Copy)] +enum Way { + /// Slides to its place from this offset. + Slide(f64), + Fold(Fold), } -impl Motion { - /// Its offset and opacity; beyond `reach` of both ends it cannot be seen, so its text - /// never meets a neighbour's, nor arriving while a row sliding is `slack` from its place. - fn at(self, reach: f64, slack: f64) -> (f64, f32) { - let t = (self.elapsed / DURATION).min(1.0); - let [first, second] = [(2.0 * t).min(1.0), (2.0 * t - 1.0).max(0.0)]; - let out = |x: f32| 1.0 - (1.0 - x).powi(3); - let inward = |x: f32| x.powi(3); - let leave = self.alpha * (1.0 - out(first)); - let (travel, fade) = match self.style { - Style::Slide => (out(second), 1.0), - Style::Cross => ( - 0.5 * inward(first) + 0.5 * out(second), - if t < 0.5 { leave } else { inward(second) }, - ), - Style::Enter => (out(second), inward(second)), - Style::Leave => (inward(first), leave), - }; - let offset = self.from + (self.to - self.from) * f64::from(travel); - let near = (offset - self.from).abs().min((offset - self.to).abs()); - let seen = |distance: f64| (1.0 - distance / reach).clamp(0.0, 1.0) as f32; - let alpha = match self.style { - Style::Slide => 1.0, - Style::Enter => fade.min(seen(near)) * seen(slack), - Style::Cross if t >= 0.5 => fade.min(seen(near)) * seen(slack), - _ => fade.min(seen(near)), - }; - (offset, alpha) +/// A row folding into the gap its run leaves between two rows, or unfolding out of the +/// gap its run opens, squeezed among the run and blending into the background as the gap +/// narrows. +#[derive(Clone, Copy)] +struct Fold { + /// The gap's edges as it starts and ends, as offsets from the row's place. + start: [f64; 2], + end: [f64; 2], + /// The row's position in its run, and the run's length. + at: usize, + of: usize, + /// How strongly it was drawn as it set out. + alpha: f32, +} + +impl Fold { + /// Where the row is drawn and how strongly, `progress` of the way through, never over + /// the `solid` rows drawn at these tops, in order. Its text keeps inside the gap, gone + /// before the gap is too narrow to hold it clear of the rows either side. + fn at(&self, place: f64, progress: f64, height: f64, solid: &[f64]) -> (f64, f32) { + let [top, bottom] = [0, 1] + .map(|side| place + self.start[side] + (self.end[side] - self.start[side]) * progress); + let below = solid.partition_point(|y| y + height / 2.0 <= (top + bottom) / 2.0); + let top = below + .checked_sub(1) + .map_or(top, |above| top.max(solid[above] + height)); + let bottom = solid.get(below).map_or(bottom, |y| bottom.min(*y)); + let share = (bottom - top).max(0.0) / self.of as f64; + let y = top + (self.at as f64 + 0.5) * share - height / 2.0; + let room = (2.0 * share / height - 1.0).clamp(0.0, 1.0) as f32; + (y, room * self.alpha) } } @@ -201,7 +186,7 @@ pub fn list( } } let mut clicked = None; - for shown in &state.shown { + for shown in state.shown.iter().filter(|shown| shown.index.is_some()) { let signal = ui.signal(id.child(shown.key)); let Some(index) = rows.find(shown.key).filter(|index| rows.selectable(*index)) else { continue; @@ -263,25 +248,21 @@ pub fn list( let scroll = state.scroll; let step = scroll - anchored; - let reach = height / 6.0; let dt = ui.dt; let tick = |motion: Motion| { let elapsed = motion.elapsed + dt; (elapsed < DURATION).then_some(Motion { elapsed, ..motion }) }; - // Rows built last frame, drawn in the view as anchored. - let before: HashMap = state - .shown - .iter() - .map(|shown| (shown.key, *shown)) - .collect(); - let listed: Vec<&Shown> = state + let progress = |motion: &Motion| 1.0 - (1.0 - f64::from(motion.elapsed / DURATION)).powi(3); + let listed: Vec = state .shown .iter() .filter(|shown| shown.index.is_some()) + .copied() .collect(); - // The most rows staying listed that keep their order slide; the rest would pass over - // them, so cross unseen. + let before: HashMap = listed.iter().map(|shown| (shown.key, *shown)).collect(); + // The most rows that stay in their order slide; the rest would pass over them, so fold + // away and unfold where they go, as do rows moving while partly folded. let order: Vec<(u64, usize)> = listed .iter() .filter_map(|shown| Some((shown.key, rows.find(shown.key)?))) @@ -299,81 +280,91 @@ pub fn list( } let kept: HashSet = std::iter::successors(tails.last().copied(), |at| previous[*at]) .map(|at| order[at].0) + .filter(|key| before[key].alpha >= 1.0) .collect(); - let arrive = |index: usize| { + + let settled = |index: usize| { let key = rows.key(index); let was = top(index) - anchored; let motion = before.get(&key).and_then(|old| { - if old.index.is_some() && (old.place - was).abs() < 0.5 { - return old.motion.and_then(tick); - } - let (drawn, alpha) = old.drawn(reach, state.slack); - let from = drawn - was; - let style = if alpha < 1.0 || !kept.contains(&key) { - Style::Cross + if (old.place - was).abs() < 0.5 { + old.motion.and_then(tick) } else { - Style::Slide - }; - tick(Motion { - from, - to: 0.0, - alpha, - style, - elapsed: 0.0, - }) + tick(Motion { + elapsed: 0.0, + way: Way::Slide(old.drawn - was), + }) + } }); Shown { key, place: top(index) - scroll, index: Some(index), motion, + drawn: 0.0, + alpha: 1.0, } }; - // Rows newly where the view already was, rather than scrolled into it, enter. - let entering = |index: usize| { + // Rows arriving where the view already was, rather than scrolled into it, unfold. + let arriving = |index: usize| { + let key = rows.key(index); let was = top(index) - anchored; - !fresh && !before.contains_key(&rows.key(index)) && was + height > 0.0 && was < view + match before.get(&key) { + Some(old) => (old.place - was).abs() >= 0.5 && !kept.contains(&key), + None => !fresh && was + height > 0.0 && was < view, + } }; let in_view = row_at(scroll)..(row_at(scroll + view) + 1).min(len); let mut shown = Vec::new(); let mut index = in_view.start; while index < in_view.end { - if !entering(index) { - shown.push(arrive(index)); + if !arriving(index) { + shown.push(settled(index)); index += 1; continue; } let end = (index..in_view.end) - .find(|index| !entering(*index)) + .find(|index| !arriving(*index)) .unwrap_or(in_view.end); - let middle = (top(index) + top(end - 1)) / 2.0; - shown.extend((index..end).map(|index| Shown { - key: rows.key(index), - place: top(index) - scroll, - index: Some(index), - motion: tick(Motion { - from: middle - top(index), - to: 0.0, - alpha: 0.0, - style: Style::Enter, - elapsed: 0.0, - }), + let [above, below] = [index.checked_sub(1), (end < len).then_some(end)] + .map(|side| side.map(|side| rows.key(side))); + let [first, last] = [top(index), top(end - 1) + height].map(|y| y - anchored); + // The seam opens where the rows either side were drawn, or mid-run with neither. + let seam = above + .and_then(|key| Some(before.get(&key)?.drawn + height)) + .or_else(|| below.and_then(|key| Some(before.get(&key)?.drawn))) + .unwrap_or((first + last) / 2.0); + shown.extend((index..end).map(|at| { + let was = top(at) - anchored; + Shown { + motion: tick(Motion { + elapsed: 0.0, + way: Way::Fold(Fold { + start: [seam - was; 2], + end: [first - was, last - was], + at: at - index, + of: end - index, + alpha: 1.0, + }), + }), + ..settled(at) + } })); index = end; } for old in &listed { - if let Some(index) = rows.find(old.key).filter(|index| !in_view.contains(index)) { - let moving = arrive(index); - let drawn = moving.drawn(reach, state.slack).0; - if drawn + height > 0.0 && drawn < view { - shown.push(moving); - } + if let Some(index) = rows + .find(old.key) + .filter(|index| !in_view.contains(index) && kept.contains(&old.key)) + { + shown.push(settled(index)); } } - let mut leaving: Vec = state + // Rows already folding away carry on; runs of rows gone or moving out of order start. + let mut folding: Vec = state .shown .iter() - .filter(|shown| shown.index.is_none() && rows.find(shown.key).is_none()) + .filter(|shown| shown.index.is_none()) .filter_map(|shown| { Some(Shown { place: shown.place - step, @@ -382,40 +373,94 @@ pub fn list( }) }) .collect(); - let gone = |shown: &&Shown| rows.find(shown.key).is_none(); - for run in listed.chunk_by(|a, b| gone(a) == gone(b)) { - if !gone(&run[0]) { + let staying = |shown: &Shown| { + kept.contains(&shown.key) + || rows + .find(shown.key) + .is_some_and(|index| (top(index) - anchored - shown.place).abs() < 0.5) + }; + let mut at = 0; + for run in listed.chunk_by(|a, b| staying(a) == staying(b)) { + let start = at; + at += run.len(); + if staying(&run[0]) { continue; } - let drawn: Vec<_> = run - .iter() - .map(|shown| shown.drawn(reach, state.slack)) - .collect(); - let middle = (drawn[0].0 + drawn[drawn.len() - 1].0) / 2.0; - leaving.extend(run.iter().zip(drawn).map(|(shown, (y, alpha))| Shown { - key: shown.key, - place: y - step, + let above = start.checked_sub(1).map(|side| listed[side].key); + let below = listed.get(at).map(|side| side.key); + let [first, last] = [run[0].drawn, run[run.len() - 1].drawn + height]; + // The seam the gap closes on: where the row below or above it now lies. + let seam = below + .and_then(|key| Some(top(rows.find(key)?) - anchored)) + .or_else(|| above.and_then(|key| Some(top(rows.find(key)?) + height - anchored))) + .unwrap_or(first); + folding.extend(run.iter().enumerate().map(|(at, old)| Shown { + key: old.key, + place: old.drawn - step, index: None, motion: tick(Motion { - from: 0.0, - to: middle - y, - alpha, - style: Style::Leave, elapsed: 0.0, + way: Way::Fold(Fold { + start: [first - old.drawn, last - old.drawn], + end: [seam - old.drawn; 2], + at, + of: run.len(), + alpha: old.alpha, + }), }), + drawn: 0.0, + alpha: 0.0, })); } - shown.sort_by(|a, b| a.place.total_cmp(&b.place)); - let slack = shown + + // Rows sliding or still are drawn first, as folds are bounded by them. + for shown in &mut shown { + if let Some(Motion { + way: Way::Slide(from), + .. + }) = shown.motion + { + shown.drawn = shown.place + from * (1.0 - progress(&shown.motion.unwrap())); + } else { + shown.drawn = shown.place; + } + } + let mut solid: Vec = shown .iter() - .filter_map(|shown| shown.motion.filter(|motion| motion.style == Style::Slide)) - .map(|motion| motion.at(reach, 0.0).0.abs()) - .fold(0.0, f64::max); + .filter(|shown| { + !matches!( + shown.motion, + Some(Motion { + way: Way::Fold(_), + .. + }) + ) + }) + .map(|shown| shown.drawn) + .collect(); + solid.sort_by(f64::total_cmp); + for shown in shown.iter_mut().chain(&mut folding) { + if let Some( + motion @ Motion { + way: Way::Fold(fold), + .. + }, + ) = shown.motion + { + (shown.drawn, shown.alpha) = fold.at(shown.place, progress(&motion), height, &solid); + } + } + shown.retain(|shown| { + shown.drawn + height > 0.0 && shown.drawn < view + || shown.index.is_some_and(|index| in_view.contains(&index)) + }); + shown.sort_by(|a, b| a.place.total_cmp(&b.place)); ui.animating |= state.scroll != state.target - || !leaving.is_empty() + || !folding.is_empty() || shown.iter().any(|shown| shown.motion.is_some()); let theme = ui.theme.clone(); + let background = spec.fill.unwrap_or(theme.base); ui.open_as( id, Spec { @@ -428,22 +473,25 @@ pub fn list( Some(rect) if most > 0.0 => px(rect[2] - rect[0] - GUTTER), _ => fill(), }; - for shown in leaving.iter().chain(&shown) { + // Rows folding away paint beneath the rest, and blend into the list rather than fade, + // so those squeezed together never add up. + for shown in folding.iter().chain(&shown) { let flags = match shown.index { Some(index) if rows.selectable(index) => Flags::FLOAT | Flags::CLICKABLE, _ => Flags::FLOAT, }; - let (y, alpha) = shown.drawn(reach, slack); - ui.open( - shown.key, - Spec { - flags, - size: [width, px(row)], - position: [0.0, y as f32], - fade: 1.0 - alpha, - ..Spec::default() - }, - ); + let row_spec = Spec { + flags, + size: [width, px(row)], + position: [0.0, shown.drawn as f32], + fade: 1.0 - shown.alpha, + fade_into: Some(background), + ..Spec::default() + }; + match shown.index { + Some(_) => ui.open(shown.key, row_spec), + None => ui.open(("folding", shown.key), row_spec), + }; build( ui, Row { @@ -468,10 +516,9 @@ pub fn list( state.target = state.scroll; } ui.close(); - shown.extend(leaving); + shown.extend(folding); shown.sort_by(|a, b| a.place.total_cmp(&b.place)); state.shown = shown; - state.slack = slack; ui.lists.insert(id, state); clicked } diff --git a/crates/ui/src/popup.rs b/crates/ui/src/popup.rs index f6d78c84fa90b3f63b72cf358da63a059ce37329..fd50ce14f321e6bf75750e0ad1010ad44f98457f 100644 --- a/crates/ui/src/popup.rs +++ b/crates/ui/src/popup.rs @@ -195,6 +195,7 @@ fn choose( id.child("rows"), Spec { size: [fill(), px(view)], + fill: Some(theme.popup), ..Spec::default() }, list, diff --git a/crates/ui/src/tests.rs b/crates/ui/src/tests.rs index ae829d425724bf19c030d8c885f7b51d15558166..95305dd93f4dc7d741432fa01f4a5de3be3ee60d 100644 --- a/crates/ui/src/tests.rs +++ b/crates/ui/src/tests.rs @@ -1027,45 +1027,52 @@ fn alpha(ui: &Ui, key: u64) -> Option { .map(|node| node.alpha) } +fn folding(ui: &Ui, key: u64) -> Option { + let id = list_id().child(("folding", key)); + ui.nodes + .iter() + .find(|node| node.id == id) + .map(|node| node.alpha) +} + #[test] -fn rows_keeping_their_order_slide_and_the_rest_fade_through() { +fn rows_slide_while_the_rest_fold_away_and_unfold_at_once() { let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); let mut selected = None; let rows = Keyed::new(0..20); settle_list(&mut ui, &rows, &mut selected); let rows = Keyed::new([5, 0, 1, 2, 3, 100, 6, 7, 8, 9, 10, 11]); list_frame(&mut ui, &rows, &mut selected, &[]); - let crossing = row_top(&ui, 5).unwrap(); - assert!(crossing < 5.0 * LIST_ROW && crossing > 4.5 * LIST_ROW); - assert!(alpha(&ui, 5).unwrap() < 1.0, "fading as it sets off"); - assert_eq!(row_top(&ui, 0), Some(0.0), "sliding waits for rows leaving"); - assert!(alpha(&ui, 4).unwrap() < 1.0); - assert_eq!(alpha(&ui, 100), Some(0.0), "entering waits too"); - for _ in 0..4 { - list_frame(&mut ui, &rows, &mut selected, &[]); - } - assert_eq!(alpha(&ui, 5), Some(0.0), "unseen half-way"); - assert_eq!(alpha(&ui, 4), Some(0.0)); let sliding = row_top(&ui, 0).unwrap(); - assert!(sliding > 0.0 && sliding < LIST_ROW); - assert_eq!( - alpha(&ui, 100), - Some(0.0), - "entering waits for rows sliding" + assert!( + sliding > 0.0 && sliding < LIST_ROW, + "sliding at once: {sliding}" ); - for _ in 0..5 { + for key in [4, 5] { + let alpha = folding(&ui, key).unwrap(); + assert!(alpha > 0.0 && alpha < 1.0, "{key} folds where it was"); + } + assert!(row_top(&ui, 5).unwrap() < 0.0, "out of the seam at the top"); + list_frame(&mut ui, &rows, &mut selected, &[]); + list_frame(&mut ui, &rows, &mut selected, &[]); + for key in [5, 100] { + let alpha = alpha(&ui, key).unwrap(); + assert!(alpha > 0.0 && alpha < 1.0, "{key} unfolds where it goes"); + } + for _ in 0..7 { list_frame(&mut ui, &rows, &mut selected, &[]); } assert_eq!(row_top(&ui, 5), Some(0.0), "there in about 150 ms"); assert_eq!(row_top(&ui, 0), Some(LIST_ROW)); + assert_eq!(row_top(&ui, 100), Some(5.0 * LIST_ROW)); assert_eq!(alpha(&ui, 100), Some(1.0)); - assert_eq!(alpha(&ui, 4), None); - list_frame(&mut ui, &rows, &mut selected, &[]); + assert_eq!((folding(&ui, 4), folding(&ui, 5)), (None, None)); assert!(!ui.wants_frame()); } /// Two rows whose text is drawn over each other: their labels, centred in each row, meet -/// within the view while both can be seen. +/// within the view while both can be seen and one is fully drawn. Rows folding together, +/// both blended into the background, may meet. fn text_overlaps(ui: &mut Ui) -> Option { let text = ui.measure("Ag")[1]; let list = ui.nodes.iter().position(|node| node.id == list_id())?; @@ -1086,7 +1093,7 @@ fn text_overlaps(ui: &mut Ui) -> Option { rows.iter().enumerate().find_map(|(at, a)| { rows[at + 1..] .iter() - .find(|b| a.0 < b.1 && b.0 < a.1) + .find(|b| a.0 < b.1 && b.0 < a.1 && a.2.max(b.2) >= 1.0) .map(|b| format!("{a:?} over {b:?}")) }) } -- 2.54.0