authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 21:57:47-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 04:23:49-07:00
log1bdd0ee601a3ec4c0ebe18ad7a462ae85df968c2
tree85c6b222ede7125c25d2fc86bcbaecacdeb4d4e8
parenta4301e095f7de2385624ba99646b141f79eb306d
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

5 files changed, 452 insertions(+), 142 deletions(-)

crates/snowbound/src/main.rs+24-37
......@@ -42,6 +42,14 @@ const FRAME: f32 = 6.0;
4242/// macOS rounds windows' corners by 10 pt; the page's corners share their centres.
4343const ROUNDING: f32 = 10.0 - FRAME;
4444const PAGE_LIST: f32 = 240.0;
45/// Height of a page tab's row, whose tab leaves `ROW_GAP` below it so tabs stand apart
46/// while the gaps still take the pointer.
47const ROW: f32 = 29.0;
48const ROW_GAP: f32 = 3.0;
49/// Space between the page and a page tab that isn't open.
50const PILL_MARGIN: f32 = 3.0;
51/// Rounding where the open page tab meets the page, concentric with its neighbours'.
52const JOIN: f32 = PILL_MARGIN + ROUNDING;
4553
4654#[derive(Debug)]
4755enum UserEvent {
......@@ -670,6 +678,7 @@ impl State {
670678 let [left, top, right, bottom] = page;
671679 let tab = open_page
672680 .and_then(|id| self.ui.rect(id))
681 .map(|row| [row[0], row[1], row[2], row[3] - ROW_GAP])
673682 .zip(panel)
674683 .and_then(|(row, panel)| {
675684 let [start, end] = [
......@@ -677,14 +686,14 @@ impl State {
677686 row[3].min(panel[3]).min(bottom),
678687 ];
679688 let reach = row[2].min(panel[2]);
680 (reach - right > 2.0 * ROUNDING && end - start > 2.0 * ROUNDING).then(|| {
689 (reach - right > 2.0 * JOIN && end - start > 2.0 * ROUNDING).then(|| {
681690 // A tab too near the page's corner joins it along the edge.
682 let start = if start < top + 2.0 * ROUNDING {
691 let start = if start < top + ROUNDING + JOIN {
683692 top
684693 } else {
685694 start
686695 };
687 let end = if end > bottom - 2.0 * ROUNDING {
696 let end = if end > bottom - ROUNDING - JOIN {
688697 bottom
689698 } else {
690699 end
......@@ -696,11 +705,11 @@ impl State {
696705 match tab {
697706 Some([start, end, reach]) => {
698707 if start > top {
699 outline.extend([([right, top], ROUNDING), ([right, start], ROUNDING)]);
708 outline.extend([([right, top], ROUNDING), ([right, start], JOIN)]);
700709 }
701710 outline.extend([([reach, start], ROUNDING), ([reach, end], ROUNDING)]);
702711 if end < bottom {
703 outline.extend([([right, end], ROUNDING), ([right, bottom], ROUNDING)]);
712 outline.extend([([right, end], JOIN), ([right, bottom], ROUNDING)]);
704713 }
705714 }
706715 None => outline.extend([([right, top], ROUNDING), ([right, bottom], ROUNDING)]),
......@@ -1074,22 +1083,15 @@ impl State {
10741083 flags: Flags::SCROLL | Flags::CLIP,
10751084 axis: Axis::Y,
10761085 size: [px(width), fill()],
1077 gap: 1.0,
10781086 ..Spec::default()
10791087 },
10801088 );
10811089 let mut open = None;
1082 let pages: Vec<_> = matching(&session.pages, &self.filter).collect();
1083 let is_open = |index: Option<usize>| {
1084 index
1085 .and_then(|index| pages.get(index))
1086 .is_some_and(|(space, ..)| *space == session.space)
1087 };
1088 for (index, (space, title, level)) in pages.iter().enumerate() {
1090 for (space, title, level) in matching(&session.pages, &self.filter) {
10891091 let selected = *space == session.space;
10901092 let spec = Spec {
10911093 flags: Flags::CLICKABLE,
1092 size: [px(PAGE_LIST), px(26.0)],
1094 size: [px(PAGE_LIST), px(ROW)],
10931095 text: Some(if title.is_empty() {
10941096 "Untitled page"
10951097 } else {
......@@ -1098,30 +1100,17 @@ impl State {
10981100 pad: [10.0 + 16.0 * level.saturating_sub(1) as f32, 0.0],
10991101 ..Spec::default()
11001102 };
1101 let clicked = if selected {
1103 let spec = if selected {
11021104 open = Some(self.ui.id(space));
1103 let spec = Spec {
1105 Spec {
11041106 color: Some(theme.paper_ink),
11051107 fill: Some(theme.paper),
1108 inset: [0.0, 0.0, 0.0, ROW_GAP],
11061109 ..spec
1107 };
1108 self.ui.leaf(space, spec).clicked
1110 }
11091111 } else {
11101112 // Tabs not open float free of the page as pills; only the open one joins it.
1111 self.ui.open(
1112 space,
1113 Spec {
1114 size: [px(PAGE_LIST), px(26.0)],
1115 ..Spec::default()
1116 },
1117 );
1118 // A pill beside the open tab gives up a little on that side, widening the gap.
1119 let [above, below] = [index.checked_sub(1), Some(index + 1)]
1120 .map(|neighbour| if is_open(neighbour) { 2.0 } else { 0.0 });
1121 let spec = Spec {
1122 flags: Flags::CLICKABLE | Flags::FLOAT,
1123 size: [px(PAGE_LIST - 3.0 - 6.0), px(26.0 - above - below)],
1124 position: [3.0, above],
1113 Spec {
11251114 color: Some(if title.is_empty() {
11261115 ui::mix(theme.ink, section.tab, 0.5)
11271116 } else {
......@@ -1130,13 +1119,11 @@ impl State {
11301119 fill: Some(section.tab),
11311120 hover_fill: Some(ui::mix(section.tab, section.accent, 0.3)),
11321121 radius: ROUNDING,
1122 inset: [PILL_MARGIN, 0.0, 6.0, ROW_GAP],
11331123 ..spec
1134 };
1135 let clicked = self.ui.leaf("pill", spec).clicked;
1136 self.ui.close();
1137 clicked
1124 }
11381125 };
1139 if clicked && !selected {
1126 if self.ui.leaf(space, spec).clicked && !selected {
11401127 self.commands.push(Command::OpenPage(*space));
11411128 }
11421129 }
crates/ui/examples/popups.rs+89-7
......@@ -1,6 +1,7 @@
11//! Renders each popup control open, in both themes, to `/tmp/ui-popups-{dark,light}.png`,
2//! and a combo's list reordering after a keystroke to `/tmp/ui-list-{dark,light}.png`, for
3//! review; then times a list of 200 000 rows.
2//! and frames of lists changing to `/tmp/ui-list-{keystroke,delete}-{dark,light}.png`: a
3//! combo's list after `c` is typed into its filter, and a page list losing rows. Then times
4//! a list of 200 000 rows.
45
56use std::{
67 collections::HashMap,
......@@ -52,13 +53,25 @@ fn main() {
5253 let path = format!("/tmp/ui-popups-{name}.png");
5354 render(panels, 3, &path);
5455 println!("{path}");
55 let path = format!("/tmp/ui-list-{name}.png");
56 render(keystroke(&theme, &device, &queue, &mut renderer), 4, &path);
57 println!("{path}");
56 let changes = [
57 (
58 "keystroke",
59 keystroke(&theme, &device, &queue, &mut renderer),
60 ),
61 ("delete", delete(&theme, &device, &queue, &mut renderer)),
62 ];
63 for (change, panels) in changes {
64 let path = format!("/tmp/ui-list-{change}-{name}.png");
65 render(panels, 5, &path);
66 println!("{path}");
67 }
5868 }
5969 measure();
6070}
6171
72/// Frames a change shows at: before it, then each of the 150 ms it takes.
73const FRAMES: [u32; 9] = [1, 2, 3, 4, 5, 6, 7, 8, 10];
74
6275/// A font combo's list before `c` is typed into its filter and at frames after.
6376fn keystroke(
6477 theme: &Theme,
......@@ -82,9 +95,78 @@ fn keystroke(
8295 for event in typed("c") {
8396 scene.ui.event(event);
8497 }
85 for frame in 1..=20 {
98 for frame in 1..=10 {
8699 scene.frame(build);
87 if [1, 2, 3, 4, 6, 9, 20].contains(&frame) {
100 if FRAMES.contains(&frame) {
101 panels.push(paint(device, queue, renderer, &scene.ui));
102 }
103 }
104 panels
105}
106
107/// A page list before rows 3 to 5 and 9 are deleted and at frames after.
108fn delete(
109 theme: &Theme,
110 device: &wgpu::Device,
111 queue: &wgpu::Queue,
112 renderer: &mut draw::Renderer,
113) -> Vec<Vec<u8>> {
114 let mut scene = Scene {
115 ui: Ui::new(theme.clone(), Duration::from_millis(500)),
116 start: Instant::now(),
117 frames: 0,
118 };
119 let names: Vec<String> = (0..30).map(|key| format!("Page {key}")).collect();
120 let mut selected = Some(7);
121 let mut build = |scene: &mut Scene, rows: &Keyed| {
122 scene.frame(|ui, _| {
123 let list = List {
124 rows,
125 row: 26.0,
126 keys: &[],
127 hover_selects: false,
128 };
129 let spec = Spec {
130 size: [px(240.0), px(26.0 * 11.0)],
131 fill: Some(ui.theme.panel),
132 ..Spec::default()
133 };
134 let theme = ui.theme.clone();
135 ui::list(
136 ui,
137 Id::ROOT.child("pages"),
138 spec,
139 list,
140 &mut selected,
141 |ui, row| {
142 ui.leaf(
143 "name",
144 Spec {
145 size: [fill(), fill()],
146 text: Some(&names[row.key as usize]),
147 fill: row.selected.then(|| theme.hover()),
148 radius: 4.0,
149 pad: [8.0, 0.0],
150 ..Spec::default()
151 },
152 );
153 },
154 );
155 });
156 };
157 let all = Keyed::new((0..30).collect());
158 for _ in 0..20 {
159 build(&mut scene, &all);
160 }
161 let mut panels = vec![paint(device, queue, renderer, &scene.ui)];
162 let kept = Keyed::new(
163 (0..30)
164 .filter(|key| !(3..=5).contains(key) && *key != 9)
165 .collect(),
166 );
167 for frame in 1..=10 {
168 build(&mut scene, &kept);
169 if FRAMES.contains(&frame) {
88170 panels.push(paint(device, queue, renderer, &scene.ui));
89171 }
90172 }
crates/ui/src/lib.rs+26-14
......@@ -222,6 +222,9 @@ pub struct Spec<'a> {
222222 pub gradient: Option<[f32; 4]>,
223223 /// A soft shadow of the box's outline, painted beneath it.
224224 pub shadow: Option<[f32; 4]>,
225 /// How far the box draws inside its leading, top, trailing and bottom edges: fill,
226 /// border, shadow, icon and label; the pointer still finds the whole box.
227 pub inset: [f32; 4],
225228 /// The fill under the pointer, blended in as hover animates.
226229 pub hover_fill: Option<[f32; 4]>,
227230 pub border: Option<[f32; 4]>,
......@@ -310,6 +313,7 @@ struct Built {
310313 fill: Option<[f32; 4]>,
311314 gradient: Option<[f32; 4]>,
312315 shadow: Option<[f32; 4]>,
316 inset: [f32; 4],
313317 hover_fill: Option<[f32; 4]>,
314318 border: Option<[f32; 4]>,
315319 hover_border: Option<[f32; 4]>,
......@@ -456,8 +460,8 @@ pub struct Ui {
456460 pointer: Option<[f32; 2]>,
457461 /// The pointer moved this frame.
458462 moved: bool,
459 /// The share of its remaining distance an animated value closes this frame.
460 rate: f32,
463 /// Seconds since the previous frame.
464 dt: f32,
461465 lists: HashMap<Id, list::State>,
462466 hover: Option<Id>,
463467 active: Option<Id>,
......@@ -487,7 +491,7 @@ impl Ui {
487491 popups: Vec::new(),
488492 pointer: None,
489493 moved: false,
490 rate: 1.0,
494 dt: 0.0,
491495 lists: HashMap::new(),
492496 hover: None,
493497 active: None,
......@@ -776,8 +780,8 @@ impl Ui {
776780 }
777781
778782 fn ease(&mut self, dt: f32) {
783 self.dt = dt;
779784 let rate = 1.0 - 0.5_f32.powf(dt / HALF_LIFE);
780 self.rate = rate;
781785 let mut animating = false;
782786 for (id, state) in &mut self.states {
783787 let hot = self.hover == Some(*id) && self.active.is_none_or(|active| active == *id);
......@@ -965,7 +969,14 @@ impl Ui {
965969 })
966970 });
967971 let border = blend(node.border, node.hover_border);
968 let size = [rect[2] - rect[0], rect[3] - rect[1]];
972 let [left, top, right, bottom] = node.inset;
973 let painted = [
974 rect[0] + left,
975 rect[1] + top,
976 rect[2] - right,
977 rect[3] - bottom,
978 ];
979 let size = [painted[2] - painted[0], painted[3] - painted[1]];
969980 if let Some(shadow) = node.shadow {
970981 let [spread, drop] = if node.anchor.is_some() {
971982 POPUP_SHADOW
......@@ -974,7 +985,7 @@ impl Ui {
974985 };
975986 self.display.push(Display::Path {
976987 data: outline(node.shape, size, node.radius),
977 origin: [rect[0], rect[1] + drop],
988 origin: [painted[0], painted[1] + drop],
978989 style: PathStyle::Shadow(spread),
979990 colors: [shadow; 2],
980991 });
......@@ -982,7 +993,7 @@ impl Ui {
982993 if node.shape == Shape::Rounded {
983994 if fill.is_some() || border.is_some() {
984995 self.display.push(Display::Rect {
985 rect,
996 rect: painted,
986997 fill: fill.unwrap_or([0.0; 4]),
987998 shade,
988999 border,
......@@ -998,7 +1009,7 @@ impl Ui {
9981009 for (style, colors) in paints.into_iter().flatten() {
9991010 self.display.push(Display::Path {
10001011 data: data.clone(),
1001 origin: [rect[0], rect[1]],
1012 origin: [painted[0], painted[1]],
10021013 style,
10031014 colors,
10041015 });
......@@ -1019,17 +1030,17 @@ impl Ui {
10191030 });
10201031 }
10211032 let inner = [
1022 rect[0] + node.pad[0],
1023 rect[1],
1024 rect[2] - node.pad[0],
1025 rect[3],
1033 painted[0] + node.pad[0],
1034 painted[1],
1035 painted[2] - node.pad[0],
1036 painted[3],
10261037 ];
10271038 let mut x = if node.center {
10281039 (inner[0] + inner[2] - node.content_width()) / 2.0
10291040 } else {
10301041 inner[0]
10311042 };
1032 let top = (rect[1] + rect[3] - ICON) / 2.0;
1043 let top = (painted[1] + painted[3] - ICON) / 2.0;
10331044 if let Some(sources) = node.icon {
10341045 self.display.push(Display::Icon {
10351046 sources,
......@@ -1045,7 +1056,7 @@ impl Ui {
10451056 x += ICON + ICON_GAP;
10461057 }
10471058 if let Some(label) = &node.label {
1048 let y = (rect[1] + rect[3] - label.size[1]) / 2.0;
1059 let y = (painted[1] + painted[3] - label.size[1]) / 2.0;
10491060 self.display.push(Display::Text {
10501061 label: label.clone(),
10511062 origin: [x, y],
......@@ -1254,6 +1265,7 @@ impl Built {
12541265 fill: spec.fill,
12551266 gradient: spec.gradient,
12561267 shadow: spec.shadow,
1268 inset: spec.inset,
12571269 hover_fill: spec.hover_fill,
12581270 border: spec.border,
12591271 hover_border: spec.hover_border,
crates/ui/src/list.rs+217-54
......@@ -1,13 +1,15 @@
11//! A virtualized list of equal rows keyed by their items: only rows in view are built,
2//! rows ease to where their items move, and the view holds its place on the selection
3//! while items arrive and leave above it.
2//! rows move to where their items go without passing over one another, and the view holds
3//! its place on the selection while items arrive and leave above it.
44
5use crate::{Axis, Event, Flags, Id, SLIDE, Size, Spec, Ui, fill, mix, px, scrollbar};
6use std::collections::HashMap;
5use crate::{Axis, Event, Flags, HALF_LIFE, Id, Size, Spec, Ui, fill, mix, px, scrollbar};
6use std::collections::{HashMap, HashSet};
77use winit::keyboard::NamedKey;
88
99/// Room beside a scrolling list's rows for its scrollbar.
1010pub(crate) const GUTTER: f32 = 12.0;
11/// Seconds a row takes to reach its new place: half leaving where it was, half arriving.
12const DURATION: f32 = 0.15;
1113
1214/// Items a list shows, in order.
1315pub trait Rows {
......@@ -56,6 +58,8 @@ pub(crate) struct State {
5658 shown: Vec<Shown>,
5759 /// The selection last frame, so a new one scrolls into view.
5860 selected: Option<u64>,
61 /// How far the furthest sliding row was from its place last frame.
62 slack: f64,
5963}
6064
6165#[derive(Clone, Copy)]
......@@ -63,10 +67,73 @@ struct Shown {
6367 key: u64,
6468 /// Where its row lies, from the top of the view.
6569 place: f64,
66 /// How far from its place the row is drawn, easing to nothing.
67 offset: f64,
68 alpha: f32,
6970 index: Option<usize>,
71 motion: Option<Motion>,
72}
73
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)]
86struct Motion {
87 from: f64,
88 to: f64,
89 /// Its opacity as it set out.
90 alpha: f32,
91 style: Style,
92 elapsed: f32,
93}
94
95#[derive(Clone, Copy, PartialEq)]
96enum Style {
97 /// A move keeping its order with the rows around: waits out those leaving, then slides.
98 Slide,
99 /// Fades out setting off towards the midpoint and in arriving from it, unseen where
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}
107
108impl Motion {
109 /// Its offset and opacity; beyond `reach` of both ends it cannot be seen, so its text
110 /// never meets a neighbour's, nor arriving while a row sliding is `slack` from its place.
111 fn at(self, reach: f64, slack: f64) -> (f64, f32) {
112 let t = (self.elapsed / DURATION).min(1.0);
113 let [first, second] = [(2.0 * t).min(1.0), (2.0 * t - 1.0).max(0.0)];
114 let out = |x: f32| 1.0 - (1.0 - x).powi(3);
115 let inward = |x: f32| x.powi(3);
116 let leave = self.alpha * (1.0 - out(first));
117 let (travel, fade) = match self.style {
118 Style::Slide => (out(second), 1.0),
119 Style::Cross => (
120 0.5 * inward(first) + 0.5 * out(second),
121 if t < 0.5 { leave } else { inward(second) },
122 ),
123 Style::Enter => (out(second), inward(second)),
124 Style::Leave => (inward(first), leave),
125 };
126 let offset = self.from + (self.to - self.from) * f64::from(travel);
127 let near = (offset - self.from).abs().min((offset - self.to).abs());
128 let seen = |distance: f64| (1.0 - distance / reach).clamp(0.0, 1.0) as f32;
129 let alpha = match self.style {
130 Style::Slide => 1.0,
131 Style::Enter => fade.min(seen(near)) * seen(slack),
132 Style::Cross if t >= 0.5 => fade.min(seen(near)) * seen(slack),
133 _ => fade.min(seen(near)),
134 };
135 (offset, alpha)
136 }
70137}
71138
72139/// Builds `list` into a box of `spec`, calling `build` inside each row in view, and moves
......@@ -187,73 +254,166 @@ pub fn list<R: Rows>(
187254 state.selected = *selected;
188255 }
189256
190 let rate = f64::from(ui.rate);
191 let settle = |value: f64, target: f64, close: f64| {
192 if fresh || (target - value).abs() < close {
193 target
194 } else {
195 value + (target - value) * rate
196 }
197 };
198 state.scroll = settle(state.scroll, state.target, 0.25);
257 let rate = 1.0 - 0.5_f64.powf(f64::from(ui.dt / HALF_LIFE));
258 if fresh || (state.target - state.scroll).abs() < 0.25 {
259 state.scroll = state.target;
260 } else {
261 state.scroll += (state.target - state.scroll) * rate;
262 }
199263 let scroll = state.scroll;
200264 let step = scroll - anchored;
201265
202 // Where each row built last frame was drawn, in the view it was drawn in.
266 let reach = height / 6.0;
267 let dt = ui.dt;
268 let tick = |motion: Motion| {
269 let elapsed = motion.elapsed + dt;
270 (elapsed < DURATION).then_some(Motion { elapsed, ..motion })
271 };
272 // Rows built last frame, drawn in the view as anchored.
203273 let before: HashMap<u64, Shown> = state
204274 .shown
205275 .iter()
206276 .map(|shown| (shown.key, *shown))
207277 .collect();
208 let place = |index: usize| {
278 let listed: Vec<&Shown> = state
279 .shown
280 .iter()
281 .filter(|shown| shown.index.is_some())
282 .collect();
283 // The most rows staying listed that keep their order slide; the rest would pass over
284 // them, so cross unseen.
285 let order: Vec<(u64, usize)> = listed
286 .iter()
287 .filter_map(|shown| Some((shown.key, rows.find(shown.key)?)))
288 .collect();
289 let mut tails: Vec<usize> = Vec::new();
290 let mut previous = vec![None; order.len()];
291 for at in 0..order.len() {
292 let length = tails.partition_point(|tail| order[*tail].1 < order[at].1);
293 previous[at] = length.checked_sub(1).map(|length| tails[length]);
294 if length == tails.len() {
295 tails.push(at);
296 } else {
297 tails[length] = at;
298 }
299 }
300 let kept: HashSet<u64> = std::iter::successors(tails.last().copied(), |at| previous[*at])
301 .map(|at| order[at].0)
302 .collect();
303 let arrive = |index: usize| {
209304 let key = rows.key(index);
210305 let was = top(index) - anchored;
211 let (offset, alpha) = match before.get(&key) {
212 Some(old) => (old.place + old.offset - was, old.alpha),
213 None if !fresh && was + height > 0.0 && was < view => (-SLIDE as f64, 0.0),
214 None => (0.0, 1.0),
215 };
306 let motion = before.get(&key).and_then(|old| {
307 if old.index.is_some() && (old.place - was).abs() < 0.5 {
308 return 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 {
315 Style::Slide
316 };
317 tick(Motion {
318 from,
319 to: 0.0,
320 alpha,
321 style,
322 elapsed: 0.0,
323 })
324 });
216325 Shown {
217326 key,
218327 place: top(index) - scroll,
219 offset: settle(offset, 0.0, 0.5),
220 alpha: settle(f64::from(alpha), 1.0, 0.01) as f32,
221328 index: Some(index),
329 motion,
222330 }
223331 };
224 let in_view = row_at(scroll)..row_at(scroll + view + height).min(len);
225 let mut shown: Vec<Shown> = in_view.clone().map(place).collect();
226 let mut leaving = Vec::new();
227 for old in before.values() {
228 match rows.find(old.key) {
229 Some(index) if !in_view.contains(&index) => {
230 let moving = place(index);
231 let drawn = moving.place + moving.offset;
232 if drawn + height > 0.0 && drawn < view {
233 shown.push(moving);
234 }
235 }
236 Some(_) => {}
237 None => {
238 let alpha = settle(f64::from(old.alpha), 0.0, 0.02) as f32;
239 if alpha > 0.0 {
240 leaving.push(Shown {
241 place: old.place + old.offset - step,
242 offset: 0.0,
243 alpha,
244 index: None,
245 ..*old
246 });
247 }
332 // Rows newly where the view already was, rather than scrolled into it, enter.
333 let entering = |index: usize| {
334 let was = top(index) - anchored;
335 !fresh && !before.contains_key(&rows.key(index)) && was + height > 0.0 && was < view
336 };
337 let in_view = row_at(scroll)..(row_at(scroll + view) + 1).min(len);
338 let mut shown = Vec::new();
339 let mut index = in_view.start;
340 while index < in_view.end {
341 if !entering(index) {
342 shown.push(arrive(index));
343 index += 1;
344 continue;
345 }
346 let end = (index..in_view.end)
347 .find(|index| !entering(*index))
348 .unwrap_or(in_view.end);
349 let middle = (top(index) + top(end - 1)) / 2.0;
350 shown.extend((index..end).map(|index| Shown {
351 key: rows.key(index),
352 place: top(index) - scroll,
353 index: Some(index),
354 motion: tick(Motion {
355 from: middle - top(index),
356 to: 0.0,
357 alpha: 0.0,
358 style: Style::Enter,
359 elapsed: 0.0,
360 }),
361 }));
362 index = end;
363 }
364 for old in &listed {
365 if let Some(index) = rows.find(old.key).filter(|index| !in_view.contains(index)) {
366 let moving = arrive(index);
367 let drawn = moving.drawn(reach, state.slack).0;
368 if drawn + height > 0.0 && drawn < view {
369 shown.push(moving);
248370 }
249371 }
250372 }
373 let mut leaving: Vec<Shown> = state
374 .shown
375 .iter()
376 .filter(|shown| shown.index.is_none() && rows.find(shown.key).is_none())
377 .filter_map(|shown| {
378 Some(Shown {
379 place: shown.place - step,
380 motion: Some(tick(shown.motion?)?),
381 ..*shown
382 })
383 })
384 .collect();
385 let gone = |shown: &&Shown| rows.find(shown.key).is_none();
386 for run in listed.chunk_by(|a, b| gone(a) == gone(b)) {
387 if !gone(&run[0]) {
388 continue;
389 }
390 let drawn: Vec<_> = run
391 .iter()
392 .map(|shown| shown.drawn(reach, state.slack))
393 .collect();
394 let middle = (drawn[0].0 + drawn[drawn.len() - 1].0) / 2.0;
395 leaving.extend(run.iter().zip(drawn).map(|(shown, (y, alpha))| Shown {
396 key: shown.key,
397 place: y - step,
398 index: None,
399 motion: tick(Motion {
400 from: 0.0,
401 to: middle - y,
402 alpha,
403 style: Style::Leave,
404 elapsed: 0.0,
405 }),
406 }));
407 }
251408 shown.sort_by(|a, b| a.place.total_cmp(&b.place));
409 let slack = shown
410 .iter()
411 .filter_map(|shown| shown.motion.filter(|motion| motion.style == Style::Slide))
412 .map(|motion| motion.at(reach, 0.0).0.abs())
413 .fold(0.0, f64::max);
252414 ui.animating |= state.scroll != state.target
253415 || !leaving.is_empty()
254 || shown
255 .iter()
256 .any(|shown| shown.offset != 0.0 || shown.alpha < 1.0);
416 || shown.iter().any(|shown| shown.motion.is_some());
257417
258418 let theme = ui.theme.clone();
259419 ui.open_as(
......@@ -273,13 +433,14 @@ pub fn list<R: Rows>(
273433 Some(index) if rows.selectable(index) => Flags::FLOAT | Flags::CLICKABLE,
274434 _ => Flags::FLOAT,
275435 };
436 let (y, alpha) = shown.drawn(reach, slack);
276437 ui.open(
277438 shown.key,
278439 Spec {
279440 flags,
280441 size: [width, px(row)],
281 position: [0.0, (shown.place + shown.offset) as f32],
282 fade: 1.0 - shown.alpha,
442 position: [0.0, y as f32],
443 fade: 1.0 - alpha,
283444 ..Spec::default()
284445 },
285446 );
......@@ -308,7 +469,9 @@ pub fn list<R: Rows>(
308469 }
309470 ui.close();
310471 shown.extend(leaving);
472 shown.sort_by(|a, b| a.place.total_cmp(&b.place));
311473 state.shown = shown;
474 state.slack = slack;
312475 ui.lists.insert(id, state);
313476 clicked
314477}
crates/ui/src/tests.rs+96-30
......@@ -920,13 +920,14 @@ impl Rows for Keyed {
920920 }
921921}
922922
923const LIST_ROW: f32 = 20.0;
923const LIST_ROW: f32 = 26.0;
924const VIEW: f32 = 10.0 * LIST_ROW;
924925
925926fn list_id() -> Id {
926927 Id::ROOT.child("list")
927928}
928929
929/// Builds a frame of a list 200 high over `rows`, ten rows in view.
930/// Builds a frame of a list over `rows`, ten rows in view.
930931fn list_frame(
931932 ui: &mut Ui,
932933 rows: &Keyed,
......@@ -942,7 +943,7 @@ fn list_frame(
942943 hover_selects: false,
943944 };
944945 let spec = Spec {
945 size: [px(200.0), px(200.0)],
946 size: [px(200.0), px(VIEW)],
946947 ..Spec::default()
947948 };
948949 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() {
995996 settle_list(&mut ui, &rows, &mut selected);
996997 let at = row_top(&ui, 500).unwrap();
997998 assert!(
998 (0.0..200.0).contains(&at),
999 (0.0..VIEW).contains(&at),
9991000 "a new selection scrolls into view"
10001001 );
10011002 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() {
10181019 assert_eq!(row_top(&ui, 500), Some(at - 60.0));
10191020}
10201021
1022fn alpha(ui: &Ui, key: u64) -> Option<f32> {
1023 let id = list_id().child(key);
1024 ui.nodes
1025 .iter()
1026 .find(|node| node.id == id)
1027 .map(|node| node.alpha)
1028}
1029
10211030#[test]
1022fn rows_ease_to_where_their_items_move_and_fade_in_and_out() {
1031fn rows_keeping_their_order_slide_and_the_rest_fade_through() {
10231032 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
10241033 let mut selected = None;
10251034 let rows = Keyed::new(0..20);
10261035 settle_list(&mut ui, &rows, &mut selected);
1027 assert_eq!(row_top(&ui, 5), Some(5.0 * LIST_ROW));
10281036 let rows = Keyed::new([5, 0, 1, 2, 3, 100, 6, 7, 8, 9, 10, 11]);
10291037 list_frame(&mut ui, &rows, &mut selected, &[]);
1030 let alpha = |ui: &Ui, key: u64| {
1031 let id = list_id().child(key);
1032 ui.nodes
1033 .iter()
1034 .find(|node| node.id == id)
1035 .map(|node| node.alpha)
1036 };
1037 let moved = row_top(&ui, 5).unwrap();
1038 assert!(
1039 moved > 0.0 && moved < 5.0 * LIST_ROW,
1040 "on its way up: {moved}"
1041 );
1042 let arriving = alpha(&ui, 100).unwrap();
1043 assert!(arriving > 0.0 && arriving < 1.0, "fading in");
1044 let leaving = alpha(&ui, 4).unwrap();
1045 assert!(leaving > 0.0 && leaving < 1.0, "fading out");
1046 for _ in 0..10 {
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 {
10471045 list_frame(&mut ui, &rows, &mut selected, &[]);
10481046 }
1049 assert!(
1050 row_top(&ui, 5).unwrap() < 3.0,
1051 "all but there within about 150 ms"
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();
1050 assert!(sliding > 0.0 && sliding < LIST_ROW);
1051 assert_eq!(
1052 alpha(&ui, 100),
1053 Some(0.0),
1054 "entering waits for rows sliding"
10521055 );
1053 settle_list(&mut ui, &rows, &mut selected);
1054 assert_eq!(row_top(&ui, 5), Some(0.0));
1056 for _ in 0..5 {
1057 list_frame(&mut ui, &rows, &mut selected, &[]);
1058 }
1059 assert_eq!(row_top(&ui, 5), Some(0.0), "there in about 150 ms");
1060 assert_eq!(row_top(&ui, 0), Some(LIST_ROW));
10551061 assert_eq!(alpha(&ui, 100), Some(1.0));
10561062 assert_eq!(alpha(&ui, 4), None);
1063 list_frame(&mut ui, &rows, &mut selected, &[]);
1064 assert!(!ui.wants_frame());
1065}
1066
1067/// 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.
1069fn text_overlaps(ui: &mut Ui) -> Option<String> {
1070 let text = ui.measure("Ag")[1];
1071 let list = ui.nodes.iter().position(|node| node.id == list_id())?;
1072 let rows: Vec<_> = ui
1073 .nodes
1074 .iter()
1075 // Below half an 8-bit step, a row changes no pixel.
1076 .filter(|node| {
1077 node.parent == list && node.size[1] == px(LIST_ROW) && node.alpha * 255.0 >= 0.5
1078 })
1079 .map(|node| {
1080 let middle = (node.rect[1] + node.rect[3]) / 2.0;
1081 let [top, bottom] =
1082 [middle - text / 2.0, middle + text / 2.0].map(|y| y.clamp(0.0, VIEW));
1083 (top, bottom, node.alpha)
1084 })
1085 .collect();
1086 rows.iter().enumerate().find_map(|(at, a)| {
1087 rows[at + 1..]
1088 .iter()
1089 .find(|b| a.0 < b.1 && b.0 < a.1)
1090 .map(|b| format!("{a:?} over {b:?}"))
1091 })
1092}
1093
1094#[test]
1095fn rows_never_draw_text_over_each_other() {
1096 let orders: [Vec<u64>; 7] = [
1097 (0..30).collect(),
1098 (4..12).chain([2, 3, 16]).chain(12..16).collect(),
1099 (0..30).rev().collect(),
1100 [7, 3, 12, 0, 1, 25, 26, 2, 4, 5].into(),
1101 (0..30)
1102 .filter(|key| !(3..6).contains(key) && *key != 8)
1103 .collect(),
1104 (0..3).chain(40..44).chain(3..30).collect(),
1105 [1, 0, 3, 2, 5, 4, 7, 6, 9, 8, 50].into(),
1106 ];
1107 for (at, order) in orders.iter().enumerate() {
1108 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1109 let mut selected = None;
1110 settle_list(&mut ui, &Keyed::new(0..30), &mut selected);
1111 // Each change, and another landing half-way through it.
1112 let next = &orders[(at + 1) % orders.len()];
1113 for (frame, rows) in (0..30).map(|frame| {
1114 let order = if frame < 5 { order } else { next };
1115 (frame, Keyed::new(order.iter().copied()))
1116 }) {
1117 list_frame(&mut ui, &rows, &mut selected, &[]);
1118 if let Some(overlap) = text_overlaps(&mut ui) {
1119 panic!("order {at}, frame {frame}: {overlap}");
1120 }
1121 }
1122 }
10571123}
10581124
10591125#[test]
......@@ -1069,13 +1135,13 @@ fn keys_move_the_selection_and_the_view_eases_after_it() {
10691135 list_frame(&mut ui, &rows, &mut selected, &[NamedKey::End]);
10701136 assert_eq!(selected, Some(999));
10711137 assert!(
1072 ui.wants_frame() && row_top(&ui, 999) != Some(200.0 - LIST_ROW),
1138 ui.wants_frame() && row_top(&ui, 999) != Some(VIEW - LIST_ROW),
10731139 "the view eases towards it"
10741140 );
10751141 settle_list(&mut ui, &rows, &mut selected);
10761142 assert_eq!(
10771143 row_top(&ui, 999),
1078 Some(200.0 - LIST_ROW),
1144 Some(VIEW - LIST_ROW),
10791145 "the last row at the bottom"
10801146 );
10811147 list_frame(