From 1bdd0ee601a3ec4c0ebe18ad7a462ae85df968c2 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 25 Sep 2026 21:57:47 -0700 Subject: [PATCH] feat: lists fade reordered rows through the middle; page tabs with even gaps Rows that keep their order slide to their new places; rows that change order, leave or arrive fade out toward and in from the middle of their path, so no text ever crosses. Boxes gain a paint inset, which page tabs use to leave an even gap under each tab while the whole row still takes the pointer, and the open tab's inner corners are concentric with its neighbours'. Assisted-by: claude-opus-5.5 Assisted-by: claude-opus-5 --- crates/snowbound/src/main.rs | 61 ++++---- crates/ui/examples/popups.rs | 96 ++++++++++++- crates/ui/src/lib.rs | 40 ++++-- crates/ui/src/list.rs | 271 ++++++++++++++++++++++++++++------- crates/ui/src/tests.rs | 126 ++++++++++++---- 5 files changed, 452 insertions(+), 142 deletions(-) diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 285952677850dba5087a0d7604d035f8c498da26..4be17ac4d601113010092d7f9ed2923815462aa7 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -42,6 +42,14 @@ const FRAME: f32 = 6.0; /// macOS rounds windows' corners by 10 pt; the page's corners share their centres. const ROUNDING: f32 = 10.0 - FRAME; const PAGE_LIST: f32 = 240.0; +/// Height of a page tab's row, whose tab leaves `ROW_GAP` below it so tabs stand apart +/// while the gaps still take the pointer. +const ROW: f32 = 29.0; +const ROW_GAP: f32 = 3.0; +/// Space between the page and a page tab that isn't open. +const PILL_MARGIN: f32 = 3.0; +/// Rounding where the open page tab meets the page, concentric with its neighbours'. +const JOIN: f32 = PILL_MARGIN + ROUNDING; #[derive(Debug)] enum UserEvent { @@ -670,6 +678,7 @@ impl State { let [left, top, right, bottom] = page; let tab = open_page .and_then(|id| self.ui.rect(id)) + .map(|row| [row[0], row[1], row[2], row[3] - ROW_GAP]) .zip(panel) .and_then(|(row, panel)| { let [start, end] = [ @@ -677,14 +686,14 @@ impl State { row[3].min(panel[3]).min(bottom), ]; let reach = row[2].min(panel[2]); - (reach - right > 2.0 * ROUNDING && end - start > 2.0 * ROUNDING).then(|| { + (reach - right > 2.0 * JOIN && end - start > 2.0 * ROUNDING).then(|| { // A tab too near the page's corner joins it along the edge. - let start = if start < top + 2.0 * ROUNDING { + let start = if start < top + ROUNDING + JOIN { top } else { start }; - let end = if end > bottom - 2.0 * ROUNDING { + let end = if end > bottom - ROUNDING - JOIN { bottom } else { end @@ -696,11 +705,11 @@ impl State { match tab { Some([start, end, reach]) => { if start > top { - outline.extend([([right, top], ROUNDING), ([right, start], ROUNDING)]); + outline.extend([([right, top], ROUNDING), ([right, start], JOIN)]); } outline.extend([([reach, start], ROUNDING), ([reach, end], ROUNDING)]); if end < bottom { - outline.extend([([right, end], ROUNDING), ([right, bottom], ROUNDING)]); + outline.extend([([right, end], JOIN), ([right, bottom], ROUNDING)]); } } None => outline.extend([([right, top], ROUNDING), ([right, bottom], ROUNDING)]), @@ -1074,22 +1083,15 @@ impl State { flags: Flags::SCROLL | Flags::CLIP, axis: Axis::Y, size: [px(width), fill()], - gap: 1.0, ..Spec::default() }, ); let mut open = None; - let pages: Vec<_> = matching(&session.pages, &self.filter).collect(); - let is_open = |index: Option| { - index - .and_then(|index| pages.get(index)) - .is_some_and(|(space, ..)| *space == session.space) - }; - for (index, (space, title, level)) in pages.iter().enumerate() { + for (space, title, level) in matching(&session.pages, &self.filter) { let selected = *space == session.space; let spec = Spec { flags: Flags::CLICKABLE, - size: [px(PAGE_LIST), px(26.0)], + size: [px(PAGE_LIST), px(ROW)], text: Some(if title.is_empty() { "Untitled page" } else { @@ -1098,30 +1100,17 @@ impl State { pad: [10.0 + 16.0 * level.saturating_sub(1) as f32, 0.0], ..Spec::default() }; - let clicked = if selected { + let spec = if selected { open = Some(self.ui.id(space)); - let spec = Spec { + Spec { color: Some(theme.paper_ink), fill: Some(theme.paper), + inset: [0.0, 0.0, 0.0, ROW_GAP], ..spec - }; - self.ui.leaf(space, spec).clicked + } } else { // Tabs not open float free of the page as pills; only the open one joins it. - self.ui.open( - space, - Spec { - size: [px(PAGE_LIST), px(26.0)], - ..Spec::default() - }, - ); - // A pill beside the open tab gives up a little on that side, widening the gap. - let [above, below] = [index.checked_sub(1), Some(index + 1)] - .map(|neighbour| if is_open(neighbour) { 2.0 } else { 0.0 }); - let spec = Spec { - flags: Flags::CLICKABLE | Flags::FLOAT, - size: [px(PAGE_LIST - 3.0 - 6.0), px(26.0 - above - below)], - position: [3.0, above], + Spec { color: Some(if title.is_empty() { ui::mix(theme.ink, section.tab, 0.5) } else { @@ -1130,13 +1119,11 @@ impl State { fill: Some(section.tab), hover_fill: Some(ui::mix(section.tab, section.accent, 0.3)), radius: ROUNDING, + inset: [PILL_MARGIN, 0.0, 6.0, ROW_GAP], ..spec - }; - let clicked = self.ui.leaf("pill", spec).clicked; - self.ui.close(); - clicked + } }; - if clicked && !selected { + if self.ui.leaf(space, spec).clicked && !selected { self.commands.push(Command::OpenPage(*space)); } } diff --git a/crates/ui/examples/popups.rs b/crates/ui/examples/popups.rs index 26b86679a1437c4bdb55d7c5f8f0a6d090ba1fb1..6594dcd62f64420f5af63db7fac3d6624f1ac956 100644 --- a/crates/ui/examples/popups.rs +++ b/crates/ui/examples/popups.rs @@ -1,6 +1,7 @@ //! Renders each popup control open, in both themes, to `/tmp/ui-popups-{dark,light}.png`, -//! and a combo's list reordering after a keystroke to `/tmp/ui-list-{dark,light}.png`, for -//! review; then times a list of 200 000 rows. +//! and frames of lists changing to `/tmp/ui-list-{keystroke,delete}-{dark,light}.png`: a +//! combo's list after `c` is typed into its filter, and a page list losing rows. Then times +//! a list of 200 000 rows. use std::{ collections::HashMap, @@ -52,13 +53,25 @@ fn main() { let path = format!("/tmp/ui-popups-{name}.png"); render(panels, 3, &path); println!("{path}"); - let path = format!("/tmp/ui-list-{name}.png"); - render(keystroke(&theme, &device, &queue, &mut renderer), 4, &path); - println!("{path}"); + let changes = [ + ( + "keystroke", + keystroke(&theme, &device, &queue, &mut renderer), + ), + ("delete", delete(&theme, &device, &queue, &mut renderer)), + ]; + for (change, panels) in changes { + let path = format!("/tmp/ui-list-{change}-{name}.png"); + render(panels, 5, &path); + println!("{path}"); + } } measure(); } +/// Frames a change shows at: before it, then each of the 150 ms it takes. +const FRAMES: [u32; 9] = [1, 2, 3, 4, 5, 6, 7, 8, 10]; + /// A font combo's list before `c` is typed into its filter and at frames after. fn keystroke( theme: &Theme, @@ -82,9 +95,78 @@ fn keystroke( for event in typed("c") { scene.ui.event(event); } - for frame in 1..=20 { + for frame in 1..=10 { scene.frame(build); - if [1, 2, 3, 4, 6, 9, 20].contains(&frame) { + if FRAMES.contains(&frame) { + panels.push(paint(device, queue, renderer, &scene.ui)); + } + } + panels +} + +/// A page list before rows 3 to 5 and 9 are deleted and at frames after. +fn delete( + theme: &Theme, + device: &wgpu::Device, + queue: &wgpu::Queue, + renderer: &mut draw::Renderer, +) -> Vec> { + let mut scene = Scene { + ui: Ui::new(theme.clone(), Duration::from_millis(500)), + start: Instant::now(), + frames: 0, + }; + let names: Vec = (0..30).map(|key| format!("Page {key}")).collect(); + let mut selected = Some(7); + let mut build = |scene: &mut Scene, rows: &Keyed| { + scene.frame(|ui, _| { + let list = List { + rows, + row: 26.0, + keys: &[], + hover_selects: false, + }; + let spec = Spec { + size: [px(240.0), px(26.0 * 11.0)], + fill: Some(ui.theme.panel), + ..Spec::default() + }; + let theme = ui.theme.clone(); + ui::list( + ui, + Id::ROOT.child("pages"), + spec, + list, + &mut selected, + |ui, row| { + ui.leaf( + "name", + Spec { + size: [fill(), fill()], + text: Some(&names[row.key as usize]), + fill: row.selected.then(|| theme.hover()), + radius: 4.0, + pad: [8.0, 0.0], + ..Spec::default() + }, + ); + }, + ); + }); + }; + let all = Keyed::new((0..30).collect()); + for _ in 0..20 { + build(&mut scene, &all); + } + let mut panels = vec![paint(device, queue, renderer, &scene.ui)]; + let kept = Keyed::new( + (0..30) + .filter(|key| !(3..=5).contains(key) && *key != 9) + .collect(), + ); + for frame in 1..=10 { + build(&mut scene, &kept); + if FRAMES.contains(&frame) { panels.push(paint(device, queue, renderer, &scene.ui)); } } diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 1b65b932106cd8134d8f4b715f48b81906cb7ec6..485791734493ffb19e9cdfdb1e896f1cb482e8f3 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -222,6 +222,9 @@ pub struct Spec<'a> { pub gradient: Option<[f32; 4]>, /// A soft shadow of the box's outline, painted beneath it. pub shadow: Option<[f32; 4]>, + /// How far the box draws inside its leading, top, trailing and bottom edges: fill, + /// border, shadow, icon and label; the pointer still finds the whole box. + pub inset: [f32; 4], /// The fill under the pointer, blended in as hover animates. pub hover_fill: Option<[f32; 4]>, pub border: Option<[f32; 4]>, @@ -310,6 +313,7 @@ struct Built { fill: Option<[f32; 4]>, gradient: Option<[f32; 4]>, shadow: Option<[f32; 4]>, + inset: [f32; 4], hover_fill: Option<[f32; 4]>, border: Option<[f32; 4]>, hover_border: Option<[f32; 4]>, @@ -456,8 +460,8 @@ pub struct Ui { pointer: Option<[f32; 2]>, /// The pointer moved this frame. moved: bool, - /// The share of its remaining distance an animated value closes this frame. - rate: f32, + /// Seconds since the previous frame. + dt: f32, lists: HashMap, hover: Option, active: Option, @@ -487,7 +491,7 @@ impl Ui { popups: Vec::new(), pointer: None, moved: false, - rate: 1.0, + dt: 0.0, lists: HashMap::new(), hover: None, active: None, @@ -776,8 +780,8 @@ impl Ui { } fn ease(&mut self, dt: f32) { + self.dt = dt; let rate = 1.0 - 0.5_f32.powf(dt / HALF_LIFE); - self.rate = rate; let mut animating = false; for (id, state) in &mut self.states { let hot = self.hover == Some(*id) && self.active.is_none_or(|active| active == *id); @@ -965,7 +969,14 @@ impl Ui { }) }); let border = blend(node.border, node.hover_border); - let size = [rect[2] - rect[0], rect[3] - rect[1]]; + let [left, top, right, bottom] = node.inset; + let painted = [ + rect[0] + left, + rect[1] + top, + rect[2] - right, + rect[3] - bottom, + ]; + let size = [painted[2] - painted[0], painted[3] - painted[1]]; if let Some(shadow) = node.shadow { let [spread, drop] = if node.anchor.is_some() { POPUP_SHADOW @@ -974,7 +985,7 @@ impl Ui { }; self.display.push(Display::Path { data: outline(node.shape, size, node.radius), - origin: [rect[0], rect[1] + drop], + origin: [painted[0], painted[1] + drop], style: PathStyle::Shadow(spread), colors: [shadow; 2], }); @@ -982,7 +993,7 @@ impl Ui { if node.shape == Shape::Rounded { if fill.is_some() || border.is_some() { self.display.push(Display::Rect { - rect, + rect: painted, fill: fill.unwrap_or([0.0; 4]), shade, border, @@ -998,7 +1009,7 @@ impl Ui { for (style, colors) in paints.into_iter().flatten() { self.display.push(Display::Path { data: data.clone(), - origin: [rect[0], rect[1]], + origin: [painted[0], painted[1]], style, colors, }); @@ -1019,17 +1030,17 @@ impl Ui { }); } let inner = [ - rect[0] + node.pad[0], - rect[1], - rect[2] - node.pad[0], - rect[3], + painted[0] + node.pad[0], + painted[1], + painted[2] - node.pad[0], + painted[3], ]; let mut x = if node.center { (inner[0] + inner[2] - node.content_width()) / 2.0 } else { inner[0] }; - let top = (rect[1] + rect[3] - ICON) / 2.0; + let top = (painted[1] + painted[3] - ICON) / 2.0; if let Some(sources) = node.icon { self.display.push(Display::Icon { sources, @@ -1045,7 +1056,7 @@ impl Ui { x += ICON + ICON_GAP; } if let Some(label) = &node.label { - let y = (rect[1] + rect[3] - label.size[1]) / 2.0; + let y = (painted[1] + painted[3] - label.size[1]) / 2.0; self.display.push(Display::Text { label: label.clone(), origin: [x, y], @@ -1254,6 +1265,7 @@ impl Built { fill: spec.fill, gradient: spec.gradient, shadow: spec.shadow, + inset: spec.inset, hover_fill: spec.hover_fill, border: spec.border, hover_border: spec.hover_border, diff --git a/crates/ui/src/list.rs b/crates/ui/src/list.rs index 0ad3800391e08ef3a2ab4b561c97a0ec7843a6cb..98210f68dea38acc18d0a12eb1485e58c01419ed 100644 --- a/crates/ui/src/list.rs +++ b/crates/ui/src/list.rs @@ -1,13 +1,15 @@ //! A virtualized list of equal rows keyed by their items: only rows in view are built, -//! rows ease to where their items move, and the view holds its place on the selection -//! while items arrive and leave above it. +//! 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. -use crate::{Axis, Event, Flags, Id, SLIDE, Size, Spec, Ui, fill, mix, px, scrollbar}; -use std::collections::HashMap; +use crate::{Axis, Event, Flags, HALF_LIFE, Id, Size, Spec, Ui, fill, mix, px, scrollbar}; +use std::collections::{HashMap, HashSet}; 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. +const DURATION: f32 = 0.15; /// Items a list shows, in order. pub trait Rows { @@ -56,6 +58,8 @@ 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)] @@ -63,10 +67,73 @@ struct Shown { key: u64, /// Where its row lies, from the top of the view. place: f64, - /// How far from its place the row is drawn, easing to nothing. - offset: f64, - alpha: f32, index: Option, + motion: Option, +} + +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, +} + +#[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, +} + +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) + } } /// Builds `list` into a box of `spec`, calling `build` inside each row in view, and moves @@ -187,73 +254,166 @@ pub fn list( state.selected = *selected; } - let rate = f64::from(ui.rate); - let settle = |value: f64, target: f64, close: f64| { - if fresh || (target - value).abs() < close { - target - } else { - value + (target - value) * rate - } - }; - state.scroll = settle(state.scroll, state.target, 0.25); + let rate = 1.0 - 0.5_f64.powf(f64::from(ui.dt / HALF_LIFE)); + if fresh || (state.target - state.scroll).abs() < 0.25 { + state.scroll = state.target; + } else { + state.scroll += (state.target - state.scroll) * rate; + } let scroll = state.scroll; let step = scroll - anchored; - // Where each row built last frame was drawn, in the view it was drawn in. + 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 place = |index: usize| { + let listed: Vec<&Shown> = state + .shown + .iter() + .filter(|shown| shown.index.is_some()) + .collect(); + // The most rows staying listed that keep their order slide; the rest would pass over + // them, so cross unseen. + let order: Vec<(u64, usize)> = listed + .iter() + .filter_map(|shown| Some((shown.key, rows.find(shown.key)?))) + .collect(); + let mut tails: Vec = Vec::new(); + let mut previous = vec![None; order.len()]; + for at in 0..order.len() { + let length = tails.partition_point(|tail| order[*tail].1 < order[at].1); + previous[at] = length.checked_sub(1).map(|length| tails[length]); + if length == tails.len() { + tails.push(at); + } else { + tails[length] = at; + } + } + let kept: HashSet = std::iter::successors(tails.last().copied(), |at| previous[*at]) + .map(|at| order[at].0) + .collect(); + let arrive = |index: usize| { let key = rows.key(index); let was = top(index) - anchored; - let (offset, alpha) = match before.get(&key) { - Some(old) => (old.place + old.offset - was, old.alpha), - None if !fresh && was + height > 0.0 && was < view => (-SLIDE as f64, 0.0), - None => (0.0, 1.0), - }; + 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 + } else { + Style::Slide + }; + tick(Motion { + from, + to: 0.0, + alpha, + style, + elapsed: 0.0, + }) + }); Shown { key, place: top(index) - scroll, - offset: settle(offset, 0.0, 0.5), - alpha: settle(f64::from(alpha), 1.0, 0.01) as f32, index: Some(index), + motion, } }; - let in_view = row_at(scroll)..row_at(scroll + view + height).min(len); - let mut shown: Vec = in_view.clone().map(place).collect(); - let mut leaving = Vec::new(); - for old in before.values() { - match rows.find(old.key) { - Some(index) if !in_view.contains(&index) => { - let moving = place(index); - let drawn = moving.place + moving.offset; - if drawn + height > 0.0 && drawn < view { - shown.push(moving); - } - } - Some(_) => {} - None => { - let alpha = settle(f64::from(old.alpha), 0.0, 0.02) as f32; - if alpha > 0.0 { - leaving.push(Shown { - place: old.place + old.offset - step, - offset: 0.0, - alpha, - index: None, - ..*old - }); - } + // Rows newly where the view already was, rather than scrolled into it, enter. + let entering = |index: usize| { + let was = top(index) - anchored; + !fresh && !before.contains_key(&rows.key(index)) && 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)); + index += 1; + continue; + } + let end = (index..in_view.end) + .find(|index| !entering(*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, + }), + })); + 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); } } } + let mut leaving: Vec = state + .shown + .iter() + .filter(|shown| shown.index.is_none() && rows.find(shown.key).is_none()) + .filter_map(|shown| { + Some(Shown { + place: shown.place - step, + motion: Some(tick(shown.motion?)?), + ..*shown + }) + }) + .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]) { + 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, + index: None, + motion: tick(Motion { + from: 0.0, + to: middle - y, + alpha, + style: Style::Leave, + elapsed: 0.0, + }), + })); + } shown.sort_by(|a, b| a.place.total_cmp(&b.place)); + let slack = 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); ui.animating |= state.scroll != state.target || !leaving.is_empty() - || shown - .iter() - .any(|shown| shown.offset != 0.0 || shown.alpha < 1.0); + || shown.iter().any(|shown| shown.motion.is_some()); let theme = ui.theme.clone(); ui.open_as( @@ -273,13 +433,14 @@ pub fn list( 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, (shown.place + shown.offset) as f32], - fade: 1.0 - shown.alpha, + position: [0.0, y as f32], + fade: 1.0 - alpha, ..Spec::default() }, ); @@ -308,7 +469,9 @@ pub fn list( } ui.close(); shown.extend(leaving); + 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/tests.rs b/crates/ui/src/tests.rs index 0a769da996ac239e03965106c2b0cf551a6eeff6..ae829d425724bf19c030d8c885f7b51d15558166 100644 --- a/crates/ui/src/tests.rs +++ b/crates/ui/src/tests.rs @@ -920,13 +920,14 @@ impl Rows for Keyed { } } -const LIST_ROW: f32 = 20.0; +const LIST_ROW: f32 = 26.0; +const VIEW: f32 = 10.0 * LIST_ROW; fn list_id() -> Id { Id::ROOT.child("list") } -/// Builds a frame of a list 200 high over `rows`, ten rows in view. +/// Builds a frame of a list over `rows`, ten rows in view. fn list_frame( ui: &mut Ui, rows: &Keyed, @@ -942,7 +943,7 @@ fn list_frame( hover_selects: false, }; let spec = Spec { - size: [px(200.0), px(200.0)], + size: [px(200.0), px(VIEW)], ..Spec::default() }; clicked = crate::list(ui, list_id(), spec, list, selected, |ui, row| { @@ -995,7 +996,7 @@ fn lists_hold_the_selection_still_as_items_arrive_and_leave_above() { settle_list(&mut ui, &rows, &mut selected); let at = row_top(&ui, 500).unwrap(); assert!( - (0.0..200.0).contains(&at), + (0.0..VIEW).contains(&at), "a new selection scrolls into view" ); rows = Keyed::new((10_000..10_100).chain(0..1000)); @@ -1018,42 +1019,107 @@ fn lists_hold_the_selection_still_as_items_arrive_and_leave_above() { assert_eq!(row_top(&ui, 500), Some(at - 60.0)); } +fn alpha(ui: &Ui, key: u64) -> Option { + let id = list_id().child(key); + ui.nodes + .iter() + .find(|node| node.id == id) + .map(|node| node.alpha) +} + #[test] -fn rows_ease_to_where_their_items_move_and_fade_in_and_out() { +fn rows_keeping_their_order_slide_and_the_rest_fade_through() { 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); - assert_eq!(row_top(&ui, 5), Some(5.0 * LIST_ROW)); let rows = Keyed::new([5, 0, 1, 2, 3, 100, 6, 7, 8, 9, 10, 11]); list_frame(&mut ui, &rows, &mut selected, &[]); - let alpha = |ui: &Ui, key: u64| { - let id = list_id().child(key); - ui.nodes - .iter() - .find(|node| node.id == id) - .map(|node| node.alpha) - }; - let moved = row_top(&ui, 5).unwrap(); - assert!( - moved > 0.0 && moved < 5.0 * LIST_ROW, - "on its way up: {moved}" - ); - let arriving = alpha(&ui, 100).unwrap(); - assert!(arriving > 0.0 && arriving < 1.0, "fading in"); - let leaving = alpha(&ui, 4).unwrap(); - assert!(leaving > 0.0 && leaving < 1.0, "fading out"); - for _ in 0..10 { + 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!( - row_top(&ui, 5).unwrap() < 3.0, - "all but there within about 150 ms" + 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" ); - settle_list(&mut ui, &rows, &mut selected); - assert_eq!(row_top(&ui, 5), Some(0.0)); + for _ in 0..5 { + 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!(alpha(&ui, 100), Some(1.0)); assert_eq!(alpha(&ui, 4), None); + list_frame(&mut ui, &rows, &mut selected, &[]); + 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. +fn text_overlaps(ui: &mut Ui) -> Option { + let text = ui.measure("Ag")[1]; + let list = ui.nodes.iter().position(|node| node.id == list_id())?; + let rows: Vec<_> = ui + .nodes + .iter() + // Below half an 8-bit step, a row changes no pixel. + .filter(|node| { + node.parent == list && node.size[1] == px(LIST_ROW) && node.alpha * 255.0 >= 0.5 + }) + .map(|node| { + let middle = (node.rect[1] + node.rect[3]) / 2.0; + let [top, bottom] = + [middle - text / 2.0, middle + text / 2.0].map(|y| y.clamp(0.0, VIEW)); + (top, bottom, node.alpha) + }) + .collect(); + rows.iter().enumerate().find_map(|(at, a)| { + rows[at + 1..] + .iter() + .find(|b| a.0 < b.1 && b.0 < a.1) + .map(|b| format!("{a:?} over {b:?}")) + }) +} + +#[test] +fn rows_never_draw_text_over_each_other() { + let orders: [Vec; 7] = [ + (0..30).collect(), + (4..12).chain([2, 3, 16]).chain(12..16).collect(), + (0..30).rev().collect(), + [7, 3, 12, 0, 1, 25, 26, 2, 4, 5].into(), + (0..30) + .filter(|key| !(3..6).contains(key) && *key != 8) + .collect(), + (0..3).chain(40..44).chain(3..30).collect(), + [1, 0, 3, 2, 5, 4, 7, 6, 9, 8, 50].into(), + ]; + for (at, order) in orders.iter().enumerate() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + let mut selected = None; + settle_list(&mut ui, &Keyed::new(0..30), &mut selected); + // Each change, and another landing half-way through it. + let next = &orders[(at + 1) % orders.len()]; + for (frame, rows) in (0..30).map(|frame| { + let order = if frame < 5 { order } else { next }; + (frame, Keyed::new(order.iter().copied())) + }) { + list_frame(&mut ui, &rows, &mut selected, &[]); + if let Some(overlap) = text_overlaps(&mut ui) { + panic!("order {at}, frame {frame}: {overlap}"); + } + } + } } #[test] @@ -1069,13 +1135,13 @@ fn keys_move_the_selection_and_the_view_eases_after_it() { list_frame(&mut ui, &rows, &mut selected, &[NamedKey::End]); assert_eq!(selected, Some(999)); assert!( - ui.wants_frame() && row_top(&ui, 999) != Some(200.0 - LIST_ROW), + ui.wants_frame() && row_top(&ui, 999) != Some(VIEW - LIST_ROW), "the view eases towards it" ); settle_list(&mut ui, &rows, &mut selected); assert_eq!( row_top(&ui, 999), - Some(200.0 - LIST_ROW), + Some(VIEW - LIST_ROW), "the last row at the bottom" ); list_frame( -- 2.54.0