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