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;...@@ -42,6 +42,14 @@ const FRAME: f32 = 6.0;
42/// macOS rounds windows' corners by 10 pt; the page's corners share their centres.42/// macOS rounds windows' corners by 10 pt; the page's corners share their centres.
43const ROUNDING: f32 = 10.0 - FRAME;43const ROUNDING: f32 = 10.0 - FRAME;
44const PAGE_LIST: f32 = 240.0;44const 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
46#[derive(Debug)]54#[derive(Debug)]
47enum UserEvent {55enum UserEvent {
...@@ -670,6 +678,7 @@ impl State {...@@ -670,6 +678,7 @@ impl State {
670 let [left, top, right, bottom] = page;678 let [left, top, right, bottom] = page;
671 let tab = open_page679 let tab = open_page
672 .and_then(|id| self.ui.rect(id))680 .and_then(|id| self.ui.rect(id))
681 .map(|row| [row[0], row[1], row[2], row[3] - ROW_GAP])
673 .zip(panel)682 .zip(panel)
674 .and_then(|(row, panel)| {683 .and_then(|(row, panel)| {
675 let [start, end] = [684 let [start, end] = [
...@@ -677,14 +686,14 @@ impl State {...@@ -677,14 +686,14 @@ impl State {
677 row[3].min(panel[3]).min(bottom),686 row[3].min(panel[3]).min(bottom),
678 ];687 ];
679 let reach = row[2].min(panel[2]);688 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(|| {
681 // A tab too near the page's corner joins it along the edge.690 // 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 {
683 top692 top
684 } else {693 } else {
685 start694 start
686 };695 };
687 let end = if end > bottom - 2.0 * ROUNDING {696 let end = if end > bottom - ROUNDING - JOIN {
688 bottom697 bottom
689 } else {698 } else {
690 end699 end
...@@ -696,11 +705,11 @@ impl State {...@@ -696,11 +705,11 @@ impl State {
696 match tab {705 match tab {
697 Some([start, end, reach]) => {706 Some([start, end, reach]) => {
698 if start > top {707 if start > top {
699 outline.extend([([right, top], ROUNDING), ([right, start], ROUNDING)]);708 outline.extend([([right, top], ROUNDING), ([right, start], JOIN)]);
700 }709 }
701 outline.extend([([reach, start], ROUNDING), ([reach, end], ROUNDING)]);710 outline.extend([([reach, start], ROUNDING), ([reach, end], ROUNDING)]);
702 if end < bottom {711 if end < bottom {
703 outline.extend([([right, end], ROUNDING), ([right, bottom], ROUNDING)]);712 outline.extend([([right, end], JOIN), ([right, bottom], ROUNDING)]);
704 }713 }
705 }714 }
706 None => outline.extend([([right, top], ROUNDING), ([right, bottom], ROUNDING)]),715 None => outline.extend([([right, top], ROUNDING), ([right, bottom], ROUNDING)]),
...@@ -1074,22 +1083,15 @@ impl State {...@@ -1074,22 +1083,15 @@ impl State {
1074 flags: Flags::SCROLL | Flags::CLIP,1083 flags: Flags::SCROLL | Flags::CLIP,
1075 axis: Axis::Y,1084 axis: Axis::Y,
1076 size: [px(width), fill()],1085 size: [px(width), fill()],
1077 gap: 1.0,
1078 ..Spec::default()1086 ..Spec::default()
1079 },1087 },
1080 );1088 );
1081 let mut open = None;1089 let mut open = None;
1082 let pages: Vec<_> = matching(&session.pages, &self.filter).collect();1090 for (space, title, level) in matching(&session.pages, &self.filter) {
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() {
1089 let selected = *space == session.space;1091 let selected = *space == session.space;
1090 let spec = Spec {1092 let spec = Spec {
1091 flags: Flags::CLICKABLE,1093 flags: Flags::CLICKABLE,
1092 size: [px(PAGE_LIST), px(26.0)],1094 size: [px(PAGE_LIST), px(ROW)],
1093 text: Some(if title.is_empty() {1095 text: Some(if title.is_empty() {
1094 "Untitled page"1096 "Untitled page"
1095 } else {1097 } else {
...@@ -1098,30 +1100,17 @@ impl State {...@@ -1098,30 +1100,17 @@ impl State {
1098 pad: [10.0 + 16.0 * level.saturating_sub(1) as f32, 0.0],1100 pad: [10.0 + 16.0 * level.saturating_sub(1) as f32, 0.0],
1099 ..Spec::default()1101 ..Spec::default()
1100 };1102 };
1101 let clicked = if selected {1103 let spec = if selected {
1102 open = Some(self.ui.id(space));1104 open = Some(self.ui.id(space));
1103 let spec = Spec {1105 Spec {
1104 color: Some(theme.paper_ink),1106 color: Some(theme.paper_ink),
1105 fill: Some(theme.paper),1107 fill: Some(theme.paper),
1108 inset: [0.0, 0.0, 0.0, ROW_GAP],
1106 ..spec1109 ..spec
1107 };1110 }
1108 self.ui.leaf(space, spec).clicked
1109 } else {1111 } else {
1110 // Tabs not open float free of the page as pills; only the open one joins it.1112 // Tabs not open float free of the page as pills; only the open one joins it.
1111 self.ui.open(1113 Spec {
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],
1125 color: Some(if title.is_empty() {1114 color: Some(if title.is_empty() {
1126 ui::mix(theme.ink, section.tab, 0.5)1115 ui::mix(theme.ink, section.tab, 0.5)
1127 } else {1116 } else {
...@@ -1130,13 +1119,11 @@ impl State {...@@ -1130,13 +1119,11 @@ impl State {
1130 fill: Some(section.tab),1119 fill: Some(section.tab),
1131 hover_fill: Some(ui::mix(section.tab, section.accent, 0.3)),1120 hover_fill: Some(ui::mix(section.tab, section.accent, 0.3)),
1132 radius: ROUNDING,1121 radius: ROUNDING,
1122 inset: [PILL_MARGIN, 0.0, 6.0, ROW_GAP],
1133 ..spec1123 ..spec
1134 };1124 }
1135 let clicked = self.ui.leaf("pill", spec).clicked;
1136 self.ui.close();
1137 clicked
1138 };1125 };
1139 if clicked && !selected {1126 if self.ui.leaf(space, spec).clicked && !selected {
1140 self.commands.push(Command::OpenPage(*space));1127 self.commands.push(Command::OpenPage(*space));
1141 }1128 }
1142 }1129 }
crates/ui/examples/popups.rs+89-7
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1//! Renders each popup control open, in both themes, to `/tmp/ui-popups-{dark,light}.png`,1//! 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`, for2//! and frames of lists changing to `/tmp/ui-list-{keystroke,delete}-{dark,light}.png`: a
3//! review; then times a list of 200 000 rows.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
5use std::{6use std::{
6 collections::HashMap,7 collections::HashMap,
...@@ -52,13 +53,25 @@ fn main() {...@@ -52,13 +53,25 @@ fn main() {
52 let path = format!("/tmp/ui-popups-{name}.png");53 let path = format!("/tmp/ui-popups-{name}.png");
53 render(panels, 3, &path);54 render(panels, 3, &path);
54 println!("{path}");55 println!("{path}");
55 let path = format!("/tmp/ui-list-{name}.png");56 let changes = [
56 render(keystroke(&theme, &device, &queue, &mut renderer), 4, &path);57 (
57 println!("{path}");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 }
58 }68 }
59 measure();69 measure();
60}70}
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
62/// A font combo's list before `c` is typed into its filter and at frames after.75/// A font combo's list before `c` is typed into its filter and at frames after.
63fn keystroke(76fn keystroke(
64 theme: &Theme,77 theme: &Theme,
...@@ -82,9 +95,78 @@ fn keystroke(...@@ -82,9 +95,78 @@ fn keystroke(
82 for event in typed("c") {95 for event in typed("c") {
83 scene.ui.event(event);96 scene.ui.event(event);
84 }97 }
85 for frame in 1..=20 {98 for frame in 1..=10 {
86 scene.frame(build);99 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) {
88 panels.push(paint(device, queue, renderer, &scene.ui));170 panels.push(paint(device, queue, renderer, &scene.ui));
89 }171 }
90 }172 }
crates/ui/src/lib.rs+26-14
...@@ -222,6 +222,9 @@ pub struct Spec<'a> {...@@ -222,6 +222,9 @@ pub struct Spec<'a> {
222 pub gradient: Option<[f32; 4]>,222 pub gradient: Option<[f32; 4]>,
223 /// A soft shadow of the box's outline, painted beneath it.223 /// A soft shadow of the box's outline, painted beneath it.
224 pub shadow: Option<[f32; 4]>,224 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],
225 /// The fill under the pointer, blended in as hover animates.228 /// The fill under the pointer, blended in as hover animates.
226 pub hover_fill: Option<[f32; 4]>,229 pub hover_fill: Option<[f32; 4]>,
227 pub border: Option<[f32; 4]>,230 pub border: Option<[f32; 4]>,
...@@ -310,6 +313,7 @@ struct Built {...@@ -310,6 +313,7 @@ struct Built {
310 fill: Option<[f32; 4]>,313 fill: Option<[f32; 4]>,
311 gradient: Option<[f32; 4]>,314 gradient: Option<[f32; 4]>,
312 shadow: Option<[f32; 4]>,315 shadow: Option<[f32; 4]>,
316 inset: [f32; 4],
313 hover_fill: Option<[f32; 4]>,317 hover_fill: Option<[f32; 4]>,
314 border: Option<[f32; 4]>,318 border: Option<[f32; 4]>,
315 hover_border: Option<[f32; 4]>,319 hover_border: Option<[f32; 4]>,
...@@ -456,8 +460,8 @@ pub struct Ui {...@@ -456,8 +460,8 @@ pub struct Ui {
456 pointer: Option<[f32; 2]>,460 pointer: Option<[f32; 2]>,
457 /// The pointer moved this frame.461 /// The pointer moved this frame.
458 moved: bool,462 moved: bool,
459 /// The share of its remaining distance an animated value closes this frame.463 /// Seconds since the previous frame.
460 rate: f32,464 dt: f32,
461 lists: HashMap<Id, list::State>,465 lists: HashMap<Id, list::State>,
462 hover: Option<Id>,466 hover: Option<Id>,
463 active: Option<Id>,467 active: Option<Id>,
...@@ -487,7 +491,7 @@ impl Ui {...@@ -487,7 +491,7 @@ impl Ui {
487 popups: Vec::new(),491 popups: Vec::new(),
488 pointer: None,492 pointer: None,
489 moved: false,493 moved: false,
490 rate: 1.0,494 dt: 0.0,
491 lists: HashMap::new(),495 lists: HashMap::new(),
492 hover: None,496 hover: None,
493 active: None,497 active: None,
...@@ -776,8 +780,8 @@ impl Ui {...@@ -776,8 +780,8 @@ impl Ui {
776 }780 }
777781
778 fn ease(&mut self, dt: f32) {782 fn ease(&mut self, dt: f32) {
783 self.dt = dt;
779 let rate = 1.0 - 0.5_f32.powf(dt / HALF_LIFE);784 let rate = 1.0 - 0.5_f32.powf(dt / HALF_LIFE);
780 self.rate = rate;
781 let mut animating = false;785 let mut animating = false;
782 for (id, state) in &mut self.states {786 for (id, state) in &mut self.states {
783 let hot = self.hover == Some(*id) && self.active.is_none_or(|active| active == *id);787 let hot = self.hover == Some(*id) && self.active.is_none_or(|active| active == *id);
...@@ -965,7 +969,14 @@ impl Ui {...@@ -965,7 +969,14 @@ impl Ui {
965 })969 })
966 });970 });
967 let border = blend(node.border, node.hover_border);971 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]];
969 if let Some(shadow) = node.shadow {980 if let Some(shadow) = node.shadow {
970 let [spread, drop] = if node.anchor.is_some() {981 let [spread, drop] = if node.anchor.is_some() {
971 POPUP_SHADOW982 POPUP_SHADOW
...@@ -974,7 +985,7 @@ impl Ui {...@@ -974,7 +985,7 @@ impl Ui {
974 };985 };
975 self.display.push(Display::Path {986 self.display.push(Display::Path {
976 data: outline(node.shape, size, node.radius),987 data: outline(node.shape, size, node.radius),
977 origin: [rect[0], rect[1] + drop],988 origin: [painted[0], painted[1] + drop],
978 style: PathStyle::Shadow(spread),989 style: PathStyle::Shadow(spread),
979 colors: [shadow; 2],990 colors: [shadow; 2],
980 });991 });
...@@ -982,7 +993,7 @@ impl Ui {...@@ -982,7 +993,7 @@ impl Ui {
982 if node.shape == Shape::Rounded {993 if node.shape == Shape::Rounded {
983 if fill.is_some() || border.is_some() {994 if fill.is_some() || border.is_some() {
984 self.display.push(Display::Rect {995 self.display.push(Display::Rect {
985 rect,996 rect: painted,
986 fill: fill.unwrap_or([0.0; 4]),997 fill: fill.unwrap_or([0.0; 4]),
987 shade,998 shade,
988 border,999 border,
...@@ -998,7 +1009,7 @@ impl Ui {...@@ -998,7 +1009,7 @@ impl Ui {
998 for (style, colors) in paints.into_iter().flatten() {1009 for (style, colors) in paints.into_iter().flatten() {
999 self.display.push(Display::Path {1010 self.display.push(Display::Path {
1000 data: data.clone(),1011 data: data.clone(),
1001 origin: [rect[0], rect[1]],1012 origin: [painted[0], painted[1]],
1002 style,1013 style,
1003 colors,1014 colors,
1004 });1015 });
...@@ -1019,17 +1030,17 @@ impl Ui {...@@ -1019,17 +1030,17 @@ impl Ui {
1019 });1030 });
1020 }1031 }
1021 let inner = [1032 let inner = [
1022 rect[0] + node.pad[0],1033 painted[0] + node.pad[0],
1023 rect[1],1034 painted[1],
1024 rect[2] - node.pad[0],1035 painted[2] - node.pad[0],
1025 rect[3],1036 painted[3],
1026 ];1037 ];
1027 let mut x = if node.center {1038 let mut x = if node.center {
1028 (inner[0] + inner[2] - node.content_width()) / 2.01039 (inner[0] + inner[2] - node.content_width()) / 2.0
1029 } else {1040 } else {
1030 inner[0]1041 inner[0]
1031 };1042 };
1032 let top = (rect[1] + rect[3] - ICON) / 2.0;1043 let top = (painted[1] + painted[3] - ICON) / 2.0;
1033 if let Some(sources) = node.icon {1044 if let Some(sources) = node.icon {
1034 self.display.push(Display::Icon {1045 self.display.push(Display::Icon {
1035 sources,1046 sources,
...@@ -1045,7 +1056,7 @@ impl Ui {...@@ -1045,7 +1056,7 @@ impl Ui {
1045 x += ICON + ICON_GAP;1056 x += ICON + ICON_GAP;
1046 }1057 }
1047 if let Some(label) = &node.label {1058 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;
1049 self.display.push(Display::Text {1060 self.display.push(Display::Text {
1050 label: label.clone(),1061 label: label.clone(),
1051 origin: [x, y],1062 origin: [x, y],
...@@ -1254,6 +1265,7 @@ impl Built {...@@ -1254,6 +1265,7 @@ impl Built {
1254 fill: spec.fill,1265 fill: spec.fill,
1255 gradient: spec.gradient,1266 gradient: spec.gradient,
1256 shadow: spec.shadow,1267 shadow: spec.shadow,
1268 inset: spec.inset,
1257 hover_fill: spec.hover_fill,1269 hover_fill: spec.hover_fill,
1258 border: spec.border,1270 border: spec.border,
1259 hover_border: spec.hover_border,1271 hover_border: spec.hover_border,
crates/ui/src/list.rs+217-54
...@@ -1,13 +1,15 @@...@@ -1,13 +1,15 @@
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 ease to where their items move, and the view holds its place on the selection2//! rows move to where their items go without passing over one another, and the view holds
3//! while items arrive and leave above it.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};5use crate::{Axis, Event, Flags, HALF_LIFE, Id, Size, Spec, Ui, fill, mix, px, scrollbar};
6use std::collections::HashMap;6use std::collections::{HashMap, HashSet};
7use winit::keyboard::NamedKey;7use winit::keyboard::NamedKey;
88
9/// Room beside a scrolling list's rows for its scrollbar.9/// Room beside a scrolling list's rows for its scrollbar.
10pub(crate) const GUTTER: f32 = 12.0;10pub(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
12/// Items a list shows, in order.14/// Items a list shows, in order.
13pub trait Rows {15pub trait Rows {
...@@ -56,6 +58,8 @@ pub(crate) struct State {...@@ -56,6 +58,8 @@ pub(crate) struct State {
56 shown: Vec<Shown>,58 shown: Vec<Shown>,
57 /// The selection last frame, so a new one scrolls into view.59 /// The selection last frame, so a new one scrolls into view.
58 selected: Option<u64>,60 selected: Option<u64>,
61 /// How far the furthest sliding row was from its place last frame.
62 slack: f64,
59}63}
6064
61#[derive(Clone, Copy)]65#[derive(Clone, Copy)]
...@@ -63,10 +67,73 @@ struct Shown {...@@ -63,10 +67,73 @@ struct Shown {
63 key: u64,67 key: u64,
64 /// Where its row lies, from the top of the view.68 /// Where its row lies, from the top of the view.
65 place: f64,69 place: f64,
66 /// How far from its place the row is drawn, easing to nothing.
67 offset: f64,
68 alpha: f32,
69 index: Option<usize>,70 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 }
70}137}
71138
72/// Builds `list` into a box of `spec`, calling `build` inside each row in view, and moves139/// 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>(...@@ -187,73 +254,166 @@ pub fn list<R: Rows>(
187 state.selected = *selected;254 state.selected = *selected;
188 }255 }
189256
190 let rate = f64::from(ui.rate);257 let rate = 1.0 - 0.5_f64.powf(f64::from(ui.dt / HALF_LIFE));
191 let settle = |value: f64, target: f64, close: f64| {258 if fresh || (state.target - state.scroll).abs() < 0.25 {
192 if fresh || (target - value).abs() < close {259 state.scroll = state.target;
193 target260 } else {
194 } else {261 state.scroll += (state.target - state.scroll) * rate;
195 value + (target - value) * rate262 }
196 }
197 };
198 state.scroll = settle(state.scroll, state.target, 0.25);
199 let scroll = state.scroll;263 let scroll = state.scroll;
200 let step = scroll - anchored;264 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.
203 let before: HashMap<u64, Shown> = state273 let before: HashMap<u64, Shown> = state
204 .shown274 .shown
205 .iter()275 .iter()
206 .map(|shown| (shown.key, *shown))276 .map(|shown| (shown.key, *shown))
207 .collect();277 .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| {
209 let key = rows.key(index);304 let key = rows.key(index);
210 let was = top(index) - anchored;305 let was = top(index) - anchored;
211 let (offset, alpha) = match before.get(&key) {306 let motion = before.get(&key).and_then(|old| {
212 Some(old) => (old.place + old.offset - was, old.alpha),307 if old.index.is_some() && (old.place - was).abs() < 0.5 {
213 None if !fresh && was + height > 0.0 && was < view => (-SLIDE as f64, 0.0),308 return old.motion.and_then(tick);
214 None => (0.0, 1.0),309 }
215 };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 });
216 Shown {325 Shown {
217 key,326 key,
218 place: top(index) - scroll,327 place: top(index) - scroll,
219 offset: settle(offset, 0.0, 0.5),
220 alpha: settle(f64::from(alpha), 1.0, 0.01) as f32,
221 index: Some(index),328 index: Some(index),
329 motion,
222 }330 }
223 };331 };
224 let in_view = row_at(scroll)..row_at(scroll + view + height).min(len);332 // Rows newly where the view already was, rather than scrolled into it, enter.
225 let mut shown: Vec<Shown> = in_view.clone().map(place).collect();333 let entering = |index: usize| {
226 let mut leaving = Vec::new();334 let was = top(index) - anchored;
227 for old in before.values() {335 !fresh && !before.contains_key(&rows.key(index)) && was + height > 0.0 && was < view
228 match rows.find(old.key) {336 };
229 Some(index) if !in_view.contains(&index) => {337 let in_view = row_at(scroll)..(row_at(scroll + view) + 1).min(len);
230 let moving = place(index);338 let mut shown = Vec::new();
231 let drawn = moving.place + moving.offset;339 let mut index = in_view.start;
232 if drawn + height > 0.0 && drawn < view {340 while index < in_view.end {
233 shown.push(moving);341 if !entering(index) {
234 }342 shown.push(arrive(index));
235 }343 index += 1;
236 Some(_) => {}344 continue;
237 None => {345 }
238 let alpha = settle(f64::from(old.alpha), 0.0, 0.02) as f32;346 let end = (index..in_view.end)
239 if alpha > 0.0 {347 .find(|index| !entering(*index))
240 leaving.push(Shown {348 .unwrap_or(in_view.end);
241 place: old.place + old.offset - step,349 let middle = (top(index) + top(end - 1)) / 2.0;
242 offset: 0.0,350 shown.extend((index..end).map(|index| Shown {
243 alpha,351 key: rows.key(index),
244 index: None,352 place: top(index) - scroll,
245 ..*old353 index: Some(index),
246 });354 motion: tick(Motion {
247 }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);
248 }370 }
249 }371 }
250 }372 }
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 }
251 shown.sort_by(|a, b| a.place.total_cmp(&b.place));408 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);
252 ui.animating |= state.scroll != state.target414 ui.animating |= state.scroll != state.target
253 || !leaving.is_empty()415 || !leaving.is_empty()
254 || shown416 || shown.iter().any(|shown| shown.motion.is_some());
255 .iter()
256 .any(|shown| shown.offset != 0.0 || shown.alpha < 1.0);
257417
258 let theme = ui.theme.clone();418 let theme = ui.theme.clone();
259 ui.open_as(419 ui.open_as(
...@@ -273,13 +433,14 @@ pub fn list<R: Rows>(...@@ -273,13 +433,14 @@ pub fn list<R: Rows>(
273 Some(index) if rows.selectable(index) => Flags::FLOAT | Flags::CLICKABLE,433 Some(index) if rows.selectable(index) => Flags::FLOAT | Flags::CLICKABLE,
274 _ => Flags::FLOAT,434 _ => Flags::FLOAT,
275 };435 };
436 let (y, alpha) = shown.drawn(reach, slack);
276 ui.open(437 ui.open(
277 shown.key,438 shown.key,
278 Spec {439 Spec {
279 flags,440 flags,
280 size: [width, px(row)],441 size: [width, px(row)],
281 position: [0.0, (shown.place + shown.offset) as f32],442 position: [0.0, y as f32],
282 fade: 1.0 - shown.alpha,443 fade: 1.0 - alpha,
283 ..Spec::default()444 ..Spec::default()
284 },445 },
285 );446 );
...@@ -308,7 +469,9 @@ pub fn list<R: Rows>(...@@ -308,7 +469,9 @@ pub fn list<R: Rows>(
308 }469 }
309 ui.close();470 ui.close();
310 shown.extend(leaving);471 shown.extend(leaving);
472 shown.sort_by(|a, b| a.place.total_cmp(&b.place));
311 state.shown = shown;473 state.shown = shown;
474 state.slack = slack;
312 ui.lists.insert(id, state);475 ui.lists.insert(id, state);
313 clicked476 clicked
314}477}
crates/ui/src/tests.rs+96-30
...@@ -920,13 +920,14 @@ impl Rows for Keyed {...@@ -920,13 +920,14 @@ impl Rows for Keyed {
920 }920 }
921}921}
922922
923const LIST_ROW: f32 = 20.0;923const LIST_ROW: f32 = 26.0;
924const VIEW: f32 = 10.0 * LIST_ROW;
924925
925fn list_id() -> Id {926fn list_id() -> Id {
926 Id::ROOT.child("list")927 Id::ROOT.child("list")
927}928}
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.
930fn list_frame(931fn list_frame(
931 ui: &mut Ui,932 ui: &mut Ui,
932 rows: &Keyed,933 rows: &Keyed,
...@@ -942,7 +943,7 @@ fn list_frame(...@@ -942,7 +943,7 @@ fn list_frame(
942 hover_selects: false,943 hover_selects: false,
943 };944 };
944 let spec = Spec {945 let spec = Spec {
945 size: [px(200.0), px(200.0)],946 size: [px(200.0), px(VIEW)],
946 ..Spec::default()947 ..Spec::default()
947 };948 };
948 clicked = crate::list(ui, list_id(), spec, list, selected, |ui, row| {949 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() {...@@ -995,7 +996,7 @@ fn lists_hold_the_selection_still_as_items_arrive_and_leave_above() {
995 settle_list(&mut ui, &rows, &mut selected);996 settle_list(&mut ui, &rows, &mut selected);
996 let at = row_top(&ui, 500).unwrap();997 let at = row_top(&ui, 500).unwrap();
997 assert!(998 assert!(
998 (0.0..200.0).contains(&at),999 (0.0..VIEW).contains(&at),
999 "a new selection scrolls into view"1000 "a new selection scrolls into view"
1000 );1001 );
1001 rows = Keyed::new((10_000..10_100).chain(0..1000));1002 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() {...@@ -1018,42 +1019,107 @@ fn lists_hold_the_selection_still_as_items_arrive_and_leave_above() {
1018 assert_eq!(row_top(&ui, 500), Some(at - 60.0));1019 assert_eq!(row_top(&ui, 500), Some(at - 60.0));
1019}1020}
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
1021#[test]1030#[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() {
1023 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);1032 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1024 let mut selected = None;1033 let mut selected = None;
1025 let rows = Keyed::new(0..20);1034 let rows = Keyed::new(0..20);
1026 settle_list(&mut ui, &rows, &mut selected);1035 settle_list(&mut ui, &rows, &mut selected);
1027 assert_eq!(row_top(&ui, 5), Some(5.0 * LIST_ROW));
1028 let rows = Keyed::new([5, 0, 1, 2, 3, 100, 6, 7, 8, 9, 10, 11]);1036 let rows = Keyed::new([5, 0, 1, 2, 3, 100, 6, 7, 8, 9, 10, 11]);
1029 list_frame(&mut ui, &rows, &mut selected, &[]);1037 list_frame(&mut ui, &rows, &mut selected, &[]);
1030 let alpha = |ui: &Ui, key: u64| {1038 let crossing = row_top(&ui, 5).unwrap();
1031 let id = list_id().child(key);1039 assert!(crossing < 5.0 * LIST_ROW && crossing > 4.5 * LIST_ROW);
1032 ui.nodes1040 assert!(alpha(&ui, 5).unwrap() < 1.0, "fading as it sets off");
1033 .iter()1041 assert_eq!(row_top(&ui, 0), Some(0.0), "sliding waits for rows leaving");
1034 .find(|node| node.id == id)1042 assert!(alpha(&ui, 4).unwrap() < 1.0);
1035 .map(|node| node.alpha)1043 assert_eq!(alpha(&ui, 100), Some(0.0), "entering waits too");
1036 };1044 for _ in 0..4 {
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 {
1047 list_frame(&mut ui, &rows, &mut selected, &[]);1045 list_frame(&mut ui, &rows, &mut selected, &[]);
1048 }1046 }
1049 assert!(1047 assert_eq!(alpha(&ui, 5), Some(0.0), "unseen half-way");
1050 row_top(&ui, 5).unwrap() < 3.0,1048 assert_eq!(alpha(&ui, 4), Some(0.0));
1051 "all but there within about 150 ms"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"
1052 );1055 );
1053 settle_list(&mut ui, &rows, &mut selected);1056 for _ in 0..5 {
1054 assert_eq!(row_top(&ui, 5), Some(0.0));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));
1055 assert_eq!(alpha(&ui, 100), Some(1.0));1061 assert_eq!(alpha(&ui, 100), Some(1.0));
1056 assert_eq!(alpha(&ui, 4), None);1062 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 }
1057}1123}
10581124
1059#[test]1125#[test]
...@@ -1069,13 +1135,13 @@ fn keys_move_the_selection_and_the_view_eases_after_it() {...@@ -1069,13 +1135,13 @@ fn keys_move_the_selection_and_the_view_eases_after_it() {
1069 list_frame(&mut ui, &rows, &mut selected, &[NamedKey::End]);1135 list_frame(&mut ui, &rows, &mut selected, &[NamedKey::End]);
1070 assert_eq!(selected, Some(999));1136 assert_eq!(selected, Some(999));
1071 assert!(1137 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),
1073 "the view eases towards it"1139 "the view eases towards it"
1074 );1140 );
1075 settle_list(&mut ui, &rows, &mut selected);1141 settle_list(&mut ui, &rows, &mut selected);
1076 assert_eq!(1142 assert_eq!(
1077 row_top(&ui, 999),1143 row_top(&ui, 999),
1078 Some(200.0 - LIST_ROW),1144 Some(VIEW - LIST_ROW),
1079 "the last row at the bottom"1145 "the last row at the bottom"
1080 );1146 );
1081 list_frame(1147 list_frame(