| author | |
| committer | |
| log | 1bdd0ee601a3ec4c0ebe18ad7a462ae85df968c2 |
| tree | 85c6b222ede7125c25d2fc86bcbaecacdeb4d4e8 |
| parent | a4301e095f7de2385624ba99646b141f79eb306d |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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-55 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. |
| 43 | const ROUNDING: f32 = 10.0 - FRAME; | 43 | const ROUNDING: f32 = 10.0 - FRAME; |
| 44 | const PAGE_LIST: f32 = 240.0; | 44 | const 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. | ||
| 47 | const ROW: f32 = 29.0; | ||
| 48 | const ROW_GAP: f32 = 3.0; | ||
| 49 | /// Space between the page and a page tab that isn't open. | ||
| 50 | const PILL_MARGIN: f32 = 3.0; | ||
| 51 | /// Rounding where the open page tab meets the page, concentric with its neighbours'. | ||
| 52 | const JOIN: f32 = PILL_MARGIN + ROUNDING; | ||
| 45 | 53 | ||
| 46 | #[derive(Debug)] | 54 | #[derive(Debug)] |
| 47 | enum UserEvent { | 55 | enum 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_page | 679 | 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 | top | 692 | top |
| 684 | } else { | 693 | } else { |
| 685 | start | 694 | start |
| 686 | }; | 695 | }; |
| 687 | let end = if end > bottom - 2.0 * ROUNDING { | 696 | let end = if end > bottom - ROUNDING - JOIN { |
| 688 | bottom | 697 | bottom |
| 689 | } else { | 698 | } else { |
| 690 | end | 699 | 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 | ..spec | 1109 | ..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 | ..spec | 1123 | ..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`, for | 2 | //! 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. | ||
| 4 | 5 | ||
| 5 | use std::{ | 6 | use 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 | } |
| 61 | 71 | ||
| 72 | /// Frames a change shows at: before it, then each of the 150 ms it takes. | ||
| 73 | const 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. |
| 63 | fn keystroke( | 76 | fn 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. | ||
| 108 | fn 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 | } |
| 777 | 781 | ||
| 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_SHADOW | 982 | 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.0 | 1039 | (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 selection | 2 | //! 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. |
| 4 | 4 | ||
| 5 | use crate::{Axis, Event, Flags, Id, SLIDE, Size, Spec, Ui, fill, mix, px, scrollbar}; | 5 | use crate::{Axis, Event, Flags, HALF_LIFE, Id, Size, Spec, Ui, fill, mix, px, scrollbar}; |
| 6 | use std::collections::HashMap; | 6 | use std::collections::{HashMap, HashSet}; |
| 7 | use winit::keyboard::NamedKey; | 7 | use winit::keyboard::NamedKey; |
| 8 | 8 | ||
| 9 | /// Room beside a scrolling list's rows for its scrollbar. | 9 | /// Room beside a scrolling list's rows for its scrollbar. |
| 10 | pub(crate) const GUTTER: f32 = 12.0; | 10 | pub(crate) const GUTTER: f32 = 12.0; |
| 11 | /// Seconds a row takes to reach its new place: half leaving where it was, half arriving. | ||
| 12 | const DURATION: f32 = 0.15; | ||
| 11 | 13 | ||
| 12 | /// Items a list shows, in order. | 14 | /// Items a list shows, in order. |
| 13 | pub trait Rows { | 15 | pub 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 | } |
| 60 | 64 | ||
| 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 | |||
| 74 | impl 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)] | ||
| 86 | struct 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)] | ||
| 96 | enum 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 | |||
| 108 | impl 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 | } |
| 71 | 138 | ||
| 72 | /// Builds `list` into a box of `spec`, calling `build` inside each row in view, and moves | 139 | /// 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 | } |
| 189 | 256 | ||
| 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 | target | 260 | } else { |
| 194 | } else { | 261 | state.scroll += (state.target - state.scroll) * rate; |
| 195 | value + (target - value) * rate | 262 | } |
| 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; |
| 201 | 265 | ||
| 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> = state | 273 | let before: HashMap<u64, Shown> = state |
| 204 | .shown | 274 | .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 | ..*old | 353 | 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.target | 414 | ui.animating |= state.scroll != state.target |
| 253 | || !leaving.is_empty() | 415 | || !leaving.is_empty() |
| 254 | || shown | 416 | || shown.iter().any(|shown| shown.motion.is_some()); |
| 255 | .iter() | ||
| 256 | .any(|shown| shown.offset != 0.0 || shown.alpha < 1.0); | ||
| 257 | 417 | ||
| 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 | clicked | 476 | 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 | } |
| 922 | 922 | ||
| 923 | const LIST_ROW: f32 = 20.0; | 923 | const LIST_ROW: f32 = 26.0; |
| 924 | const VIEW: f32 = 10.0 * LIST_ROW; | ||
| 924 | 925 | ||
| 925 | fn list_id() -> Id { | 926 | fn list_id() -> Id { |
| 926 | Id::ROOT.child("list") | 927 | Id::ROOT.child("list") |
| 927 | } | 928 | } |
| 928 | 929 | ||
| 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. |
| 930 | fn list_frame( | 931 | fn 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 | } |
| 1020 | 1021 | ||
| 1022 | fn 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] |
| 1022 | fn rows_ease_to_where_their_items_move_and_fade_in_and_out() { | 1031 | fn 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.nodes | 1040 | 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. | ||
| 1069 | fn 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] | ||
| 1095 | fn 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 | } |
| 1058 | 1124 | ||
| 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( |