587 lines
22 KiB
Rust
587 lines
22 KiB
Rust
//! The drag: the bitmap under the cursor, the press-to-selection-mode
|
||
//! hold, and the spring that opens a collapsed collection a drag is
|
||
//! dwelling over.
|
||
|
||
use std::cell::RefCell;
|
||
use std::rc::Rc;
|
||
|
||
use dr_catalog::collections::{self as coll};
|
||
use dr_catalog::Catalog;
|
||
use dr_types::CollectionId;
|
||
use slint::ComponentHandle;
|
||
|
||
use crate::{AppWindow, Library};
|
||
|
||
use super::controller::CollectionsController;
|
||
use super::tree_sync::refresh_tree;
|
||
|
||
/// Build the bitmap that travels under the cursor.
|
||
///
|
||
/// One image is drawn as itself. Several are **fanned**, back to front with the
|
||
/// topmost last, so the cursor carries a visibly thicker stack the more is being
|
||
/// dragged — the count is legible from the shape rather than needing a number.
|
||
///
|
||
/// Composited here rather than in Slint because `DragArea.drag-image` takes a
|
||
/// single bitmap, and Slint cannot render a pile of thumbnails into one.
|
||
///
|
||
/// Only the top few are drawn. A forty-image drag would otherwise be forty
|
||
/// composites for a stack whose lower layers are hidden by the ones above.
|
||
pub(super) fn compose_drag_image(thumbs: &[slint::Image]) -> slint::Image {
|
||
/// Layers drawn, at most. Past this the stack looks no thicker.
|
||
const MAX_LAYERS: usize = 4;
|
||
/// Pixel step between layers, in the composite's own space.
|
||
const FAN: u32 = 10;
|
||
/// Long edge of the composed bitmap.
|
||
const EDGE: u32 = 160;
|
||
|
||
let layers: Vec<&slint::Image> = thumbs.iter().rev().take(MAX_LAYERS).collect();
|
||
let Some(top) = layers.first() else {
|
||
return slint::Image::default();
|
||
};
|
||
|
||
// The whole composite is the top image's box plus room for the fan.
|
||
let offset = FAN * (layers.len().saturating_sub(1)) as u32;
|
||
let size = top.size();
|
||
if size.width == 0 || size.height == 0 {
|
||
return slint::Image::default();
|
||
}
|
||
// Scale the top thumbnail so its long edge is EDGE, then add the fan.
|
||
let scale = EDGE as f32 / size.width.max(size.height) as f32;
|
||
let tw = ((size.width as f32 * scale) as u32).max(1);
|
||
let th = ((size.height as f32 * scale) as u32).max(1);
|
||
|
||
let mut canvas = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(tw + offset, th + offset);
|
||
let cw = canvas.width();
|
||
let stride = cw as usize;
|
||
let pixels = canvas.make_mut_slice();
|
||
|
||
// Back to front: `layers` is already reversed, so the last drawn is the
|
||
// image the user grabbed and it lands on top.
|
||
for (n, layer) in layers.iter().enumerate().rev() {
|
||
// The furthest-back layer sits at the largest offset, so the stack fans
|
||
// down and right from the top image at (0, 0).
|
||
let dx = FAN * n as u32;
|
||
let dy = FAN * n as u32;
|
||
|
||
// Each layer is fitted to the *top* image's box rather than stretched to
|
||
// it: a portrait frame behind a landscape one would otherwise be visibly
|
||
// distorted, and the stack stops reading as a pile of photographs.
|
||
let s = layer.size();
|
||
let (lw, lh) = if s.width == 0 || s.height == 0 {
|
||
(tw, th)
|
||
} else {
|
||
let fit = (tw as f32 / s.width as f32).min(th as f32 / s.height as f32);
|
||
(
|
||
((s.width as f32 * fit) as u32).max(1),
|
||
((s.height as f32 * fit) as u32).max(1),
|
||
)
|
||
};
|
||
// Centred in the slot, so a narrower frame is not pinned to one edge.
|
||
let cx = dx + (tw - lw.min(tw)) / 2;
|
||
let cy = dy + (th - lh.min(th)) / 2;
|
||
|
||
blit_scaled(layer, pixels, stride, cx, cy, lw, lh, n > 0);
|
||
}
|
||
|
||
slint::Image::from_rgba8_premultiplied(canvas)
|
||
}
|
||
|
||
/// Hand the composite to the renderer by way of a file.
|
||
///
|
||
/// **A workaround for a renderer fault, and it should read as one.** The
|
||
/// ghost under the cursor is drawn by Slint's own drag overlay, which
|
||
/// uploads the image as a texture, draws it, and drops the texture in the
|
||
/// same call. With the wgpu FemtoVG renderer that drop is immediate and
|
||
/// the draw is deferred to the frame's flush, so by the time the frame is
|
||
/// rendered the texture is gone and the renderer binds its placeholder
|
||
/// instead — a solid red rectangle the size of the ghost. An image with a
|
||
/// cache key is kept in the renderer's texture cache until after the
|
||
/// flush; an image built from pixels has none, and only a path gives one.
|
||
/// So the composite goes to disk as a PNG and comes back through
|
||
/// `load_from_path`. Slint 1.17.1, `draw_image_direct` in the FemtoVG
|
||
/// item renderer; the GL FemtoVG renderer is not affected.
|
||
///
|
||
/// One file per drag, named uniquely: the core caches decoded images by
|
||
/// path, so reusing a name would show the previous drag's ghost. The file
|
||
/// is removed when the drag ends, or when the next one begins.
|
||
///
|
||
/// If anything on the way fails the composite is handed over as it is,
|
||
/// which on the affected renderer draws the placeholder — no worse than
|
||
/// before, and a log line says why.
|
||
pub(super) fn drag_image_via_file(composite: slint::Image) -> slint::Image {
|
||
let Some(buffer) = composite.to_rgba8() else {
|
||
log::warn!("drag ghost: the composite has no pixels to write");
|
||
return composite;
|
||
};
|
||
log::debug!("drag ghost: {}×{}", buffer.width(), buffer.height());
|
||
if buffer.width() == 0 || buffer.height() == 0 {
|
||
return composite;
|
||
}
|
||
match write_drag_image(&buffer) {
|
||
Ok(path) => match slint::Image::load_from_path(&path) {
|
||
Ok(image) => {
|
||
forget_drag_image_file();
|
||
*DRAG_IMAGE_FILE.lock().unwrap() = Some(path);
|
||
image
|
||
}
|
||
Err(_) => {
|
||
log::warn!("drag ghost: {} did not load back", path.display());
|
||
let _ = std::fs::remove_file(&path);
|
||
composite
|
||
}
|
||
},
|
||
Err(e) => {
|
||
log::warn!("drag ghost: {e}");
|
||
composite
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The file the current drag's ghost is loaded from, if any.
|
||
static DRAG_IMAGE_FILE: std::sync::Mutex<Option<std::path::PathBuf>> = std::sync::Mutex::new(None);
|
||
|
||
fn write_drag_image(
|
||
buffer: &slint::SharedPixelBuffer<slint::Rgba8Pixel>,
|
||
) -> std::io::Result<std::path::PathBuf> {
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
static SERIAL: AtomicU64 = AtomicU64::new(0);
|
||
|
||
let dir = crate::library::scratch_dir();
|
||
std::fs::create_dir_all(&dir)?;
|
||
let path = dir.join(format!(
|
||
"drag-{}-{}.png",
|
||
std::process::id(),
|
||
SERIAL.fetch_add(1, Ordering::Relaxed)
|
||
));
|
||
let file = std::fs::File::create(&path)?;
|
||
let mut encoder = png::Encoder::new(
|
||
std::io::BufWriter::new(file),
|
||
buffer.width(),
|
||
buffer.height(),
|
||
);
|
||
encoder.set_color(png::ColorType::Rgba);
|
||
encoder.set_depth(png::BitDepth::Eight);
|
||
// Fastest: this is a 160px bitmap written once per drag and read once.
|
||
encoder.set_compression(png::Compression::Fastest);
|
||
let mut writer = encoder.write_header().map_err(std::io::Error::other)?;
|
||
writer
|
||
.write_image_data(buffer.as_bytes())
|
||
.map_err(std::io::Error::other)?;
|
||
writer.finish().map_err(std::io::Error::other)?;
|
||
Ok(path)
|
||
}
|
||
|
||
pub(super) fn forget_drag_image_file() {
|
||
if let Some(path) = DRAG_IMAGE_FILE.lock().unwrap().take() {
|
||
let _ = std::fs::remove_file(path);
|
||
}
|
||
}
|
||
|
||
/// Draw one thumbnail into the composite, scaled to `tw`×`th` at `dx`,`dy`.
|
||
///
|
||
/// Nearest-neighbour: this is a transient 160px cursor bitmap, and a filtered
|
||
/// resample would cost more than it could visibly buy. `dim` darkens the layers
|
||
/// beneath the top one so the stack reads as depth rather than as a smear.
|
||
///
|
||
/// The buffer is premultiplied, so the alpha applied here is baked into the
|
||
/// colour channels as well.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn blit_scaled(
|
||
src: &slint::Image,
|
||
dst: &mut [slint::Rgba8Pixel],
|
||
stride: usize,
|
||
dx: u32,
|
||
dy: u32,
|
||
tw: u32,
|
||
th: u32,
|
||
dim: bool,
|
||
) {
|
||
let Some(buf) = src.to_rgba8() else { return };
|
||
let (sw, sh) = (buf.width(), buf.height());
|
||
if sw == 0 || sh == 0 {
|
||
return;
|
||
}
|
||
let src_px = buf.as_slice();
|
||
|
||
for y in 0..th {
|
||
let sy = (y * sh / th).min(sh - 1);
|
||
for x in 0..tw {
|
||
let sx = (x * sw / tw).min(sw - 1);
|
||
let s = src_px[(sy * sw + sx) as usize];
|
||
|
||
// Clipped per pixel on both axes. A row-major index alone would let
|
||
// an overhanging right edge wrap onto the next line, which draws as
|
||
// a smear rather than as an out-of-bounds panic.
|
||
let (px, py) = (dx + x, dy + y);
|
||
if px as usize >= stride {
|
||
continue;
|
||
}
|
||
let out = py as usize * stride + px as usize;
|
||
if out >= dst.len() {
|
||
continue;
|
||
}
|
||
// Layers below the top are darkened, not made transparent: the
|
||
// composite sits over whatever is on screen, and translucency there
|
||
// would show the desktop through the stack.
|
||
let f = if dim { 0.55 } else { 1.0 };
|
||
dst[out] = slint::Rgba8Pixel {
|
||
r: (s.r as f32 * f) as u8,
|
||
g: (s.g as f32 * f) as u8,
|
||
b: (s.b as f32 * f) as u8,
|
||
a: s.a,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
/// How long the pointer must dwell on a collapsed parent before it springs
|
||
/// open, mid-drag.
|
||
///
|
||
/// Long enough that crossing a parent on the way somewhere else does not open
|
||
/// it — a tree that flaps open under every passing pointer is worse than one
|
||
/// that never opens. Short enough to feel like a response rather than a wait;
|
||
/// this is the range file managers have settled on for the same gesture.
|
||
const SPRING_DELAY_MS: u64 = 500;
|
||
|
||
/// TRACES: FR-UI-2 | FR-UI-4
|
||
/// How long a cell must be held before the grid enters selection mode.
|
||
///
|
||
/// The platform convention, and the reason to match it rather than pick: every
|
||
/// gallery on the device this exists for opens a selection on a hold of about
|
||
/// this length, so a user who has never read a word about DarkRoom already
|
||
/// knows the gesture. Shorter and a slow tap becomes a selection; longer and
|
||
/// the hand lets go first, having concluded nothing was going to happen.
|
||
pub(crate) const HOLD_DELAY_MS: u64 = 450;
|
||
|
||
/// TRACES: FR-UI-2 | FR-UI-4
|
||
/// Start the timer that turns a held cell into a selection.
|
||
///
|
||
/// Restarted per press, and cancelled by the release — see `hold_timer`. The
|
||
/// press that armed it has *already* selected the cell under the finger, so
|
||
/// what firing adds is the mode: from here taps toggle rather than open, and
|
||
/// the header's buttons appear to act on what has been gathered.
|
||
pub(super) fn arm_hold(window: &AppWindow, ctl: &Rc<CollectionsController>, row: i32) {
|
||
let timer = slint::Timer::default();
|
||
let weak = window.as_weak();
|
||
let ctl_cb = ctl.clone();
|
||
|
||
timer.start(
|
||
slint::TimerMode::SingleShot,
|
||
std::time::Duration::from_millis(HOLD_DELAY_MS),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
|
||
// TRACES: FR-CAT-7
|
||
// The photograph is in the user's hand: the grid draws a ring
|
||
// opening around it and stops scrolling underneath it, so the drag
|
||
// that may follow cannot be lost to a flick. See `held-row` in
|
||
// `library.slint`.
|
||
//
|
||
// This half happens whether or not selection mode was already on —
|
||
// it is the half a *drag* needs, and a drag out of a selection of
|
||
// forty starts in a mode that is already on.
|
||
w.global::<Library>().set_library_held_row(row);
|
||
|
||
if !ctl_cb.select_mode.get() {
|
||
ctl_cb.select_mode.set(true);
|
||
w.global::<Library>().set_library_select_mode(true);
|
||
}
|
||
|
||
// The release that follows this hold must not also open the image:
|
||
// the user asked for a selection and would land in develop instead.
|
||
// Reusing `modified_press` rather than adding a second flag — the
|
||
// click already consults it, and it means exactly this: "the press
|
||
// was a selection gesture".
|
||
ctl_cb.modified_press.set(true);
|
||
},
|
||
);
|
||
|
||
*ctl.hold_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// Whether hovering this row should schedule a spring expansion.
|
||
///
|
||
/// Pure so the rule is testable: only a *collapsed parent* has anything to
|
||
/// open. A leaf would flash a pointless rebuild, and one already expanded is
|
||
/// where the user can already see the children.
|
||
fn should_spring(
|
||
row: Option<usize>,
|
||
row_ids: &[CollectionId],
|
||
row_has_children: &[bool],
|
||
collapsed: &std::collections::HashSet<CollectionId>,
|
||
) -> Option<CollectionId> {
|
||
let row = row?;
|
||
let &id = row_ids.get(row)?;
|
||
let has_children = row_has_children.get(row).copied().unwrap_or(false);
|
||
(has_children && collapsed.contains(&id)).then_some(id)
|
||
}
|
||
|
||
/// Start (or restart) the dwell timer that opens a collapsed collection.
|
||
///
|
||
/// Called on every hover change during a drag. Restarting on each change is
|
||
/// what makes the dwell a dwell: moving to another row cancels the pending
|
||
/// expansion instead of queueing a second one.
|
||
pub(super) fn arm_spring(
|
||
window: &AppWindow,
|
||
ctl: &Rc<CollectionsController>,
|
||
catalog: &Rc<RefCell<Option<Catalog>>>,
|
||
row: Option<usize>,
|
||
) {
|
||
// Dropping the old timer cancels it. Anything already scheduled for the row
|
||
// the pointer has just left must not fire.
|
||
*ctl.spring_timer.borrow_mut() = None;
|
||
|
||
let Some(row) = row else { return };
|
||
|
||
// Only a collapsed parent has anything to spring. A leaf, or one already
|
||
// open, is left alone rather than being pointlessly "expanded".
|
||
let target = should_spring(
|
||
Some(row),
|
||
&ctl.row_ids.borrow(),
|
||
&ctl.row_has_children.borrow(),
|
||
&ctl.collapsed.borrow(),
|
||
);
|
||
let Some(id) = target else { return };
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = window.as_weak();
|
||
let ctl_cb = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
|
||
timer.start(
|
||
slint::TimerMode::SingleShot,
|
||
std::time::Duration::from_millis(SPRING_DELAY_MS),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
// The drag may have ended, or moved on, during the dwell.
|
||
// Expanding then would rearrange the sidebar for no reason the user
|
||
// can connect to what they did. `dragging` being non-empty *is* the
|
||
// "a drag is live" test — Slint owns the gesture now, so there is no
|
||
// window flag to consult.
|
||
if ctl_cb.dragging.borrow().is_empty() || *ctl_cb.hover_id.borrow() != Some(id) {
|
||
return;
|
||
}
|
||
|
||
ctl_cb.collapsed.borrow_mut().remove(&id);
|
||
// Remembered so it can be closed again if the drag ends elsewhere.
|
||
ctl_cb.spring_opened.borrow_mut().push(id);
|
||
|
||
let borrow = catalog.borrow();
|
||
if let Some(cat) = borrow.as_ref() {
|
||
// The rebuild inserts the children below this row. The pointer
|
||
// is still over this same collection, and its own `DropArea`
|
||
// re-establishes the highlight — there is no index to re-point,
|
||
// which is the second thing the native drag API removed.
|
||
refresh_tree(&w, &ctl_cb, cat);
|
||
}
|
||
},
|
||
);
|
||
|
||
*ctl.spring_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// Close whatever the spring opened during a drag that did not land in it.
|
||
///
|
||
/// A collection the user dropped into stays open — they are working in it. One
|
||
/// merely passed over is put back, so a drag across a deep tree does not leave
|
||
/// it unfolded.
|
||
pub(super) fn collapse_spring_opened(
|
||
window: &AppWindow,
|
||
ctl: &Rc<CollectionsController>,
|
||
catalog: &Rc<RefCell<Option<Catalog>>>,
|
||
keep: Option<CollectionId>,
|
||
) {
|
||
*ctl.spring_timer.borrow_mut() = None;
|
||
let opened = std::mem::take(&mut *ctl.spring_opened.borrow_mut());
|
||
if opened.is_empty() {
|
||
return;
|
||
}
|
||
|
||
{
|
||
let mut collapsed = ctl.collapsed.borrow_mut();
|
||
for id in opened {
|
||
// The collection dropped into stays open, and so does every
|
||
// ancestor of it — closing a parent would hide the very row that
|
||
// just received the images.
|
||
let keep_this = keep.is_some_and(|k| {
|
||
k == id
|
||
|| catalog
|
||
.borrow()
|
||
.as_ref()
|
||
.and_then(|cat| coll::descendants(cat.connection(), id).ok())
|
||
.is_some_and(|d| d.contains(&k))
|
||
});
|
||
if !keep_this {
|
||
collapsed.insert(id);
|
||
}
|
||
}
|
||
}
|
||
|
||
let borrow = catalog.borrow();
|
||
if let Some(cat) = borrow.as_ref() {
|
||
refresh_tree(window, ctl, cat);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// A solid test thumbnail.
|
||
fn thumb(w: u32, h: u32) -> slint::Image {
|
||
let mut buf = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(w, h);
|
||
for p in buf.make_mut_slice() {
|
||
*p = slint::Rgba8Pixel {
|
||
r: 200,
|
||
g: 120,
|
||
b: 60,
|
||
a: 255,
|
||
};
|
||
}
|
||
slint::Image::from_rgba8(buf)
|
||
}
|
||
|
||
#[test]
|
||
fn one_dragged_image_composites_to_a_single_frame() {
|
||
let img = compose_drag_image(&[thumb(64, 64)]);
|
||
let size = img.size();
|
||
// No fan for one image: the bitmap is just the thumbnail's own box.
|
||
assert_eq!(size.width, size.height, "a square thumbnail stays square");
|
||
assert!(size.width > 0);
|
||
}
|
||
|
||
#[test]
|
||
fn a_stack_is_wider_than_a_single_image() {
|
||
// The fan is what makes the count legible from the shape rather than
|
||
// needing a number drawn on it.
|
||
let one = compose_drag_image(&[thumb(64, 64)]);
|
||
let many = compose_drag_image(&[thumb(64, 64), thumb(64, 64), thumb(64, 64)]);
|
||
|
||
assert!(
|
||
many.size().width > one.size().width,
|
||
"three images fan wider than one"
|
||
);
|
||
assert!(many.size().height > one.size().height);
|
||
}
|
||
|
||
#[test]
|
||
fn the_stack_stops_growing_past_the_layer_cap() {
|
||
// A forty-image drag must not composite forty thumbnails for a pile
|
||
// whose lower layers are hidden anyway.
|
||
let five: Vec<slint::Image> = (0..5).map(|_| thumb(64, 64)).collect();
|
||
let forty: Vec<slint::Image> = (0..40).map(|_| thumb(64, 64)).collect();
|
||
|
||
assert_eq!(
|
||
compose_drag_image(&five).size().width,
|
||
compose_drag_image(&forty).size().width,
|
||
"past the cap the stack looks no thicker"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_empty_drag_composites_to_nothing() {
|
||
// Every cell in the selection may still be waiting for its preview.
|
||
assert_eq!(compose_drag_image(&[]).size().width, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn a_portrait_thumbnail_keeps_its_proportions() {
|
||
// Fitted, not stretched: a distorted frame stops the stack reading as
|
||
// photographs.
|
||
let img = compose_drag_image(&[thumb(60, 120)]);
|
||
let size = img.size();
|
||
assert!(
|
||
size.height > size.width,
|
||
"a tall thumbnail composites tall, {}x{}",
|
||
size.width,
|
||
size.height
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn mixed_orientations_do_not_panic_or_wrap() {
|
||
// The layers below the top are fitted into its box and clipped. Getting
|
||
// that wrong draws as a smear across the next row, or panics.
|
||
let img = compose_drag_image(&[thumb(120, 60), thumb(60, 120), thumb(90, 90)]);
|
||
assert!(img.size().width > 0 && img.size().height > 0);
|
||
}
|
||
|
||
#[test]
|
||
fn a_zero_sized_thumbnail_is_not_composited() {
|
||
// A decode that produced nothing must not become a zero-divide.
|
||
assert_eq!(compose_drag_image(&[thumb(0, 0)]).size().width, 0);
|
||
}
|
||
|
||
/// The spring's inputs: rows, which have children, and which are collapsed.
|
||
fn spring_fixture() -> (
|
||
Vec<CollectionId>,
|
||
Vec<bool>,
|
||
std::collections::HashSet<CollectionId>,
|
||
) {
|
||
let ids = vec![CollectionId(1), CollectionId(2), CollectionId(3)];
|
||
// 1 is a collapsed parent, 2 an expanded parent, 3 a leaf.
|
||
let has_children = vec![true, true, false];
|
||
let collapsed = [CollectionId(1)].into_iter().collect();
|
||
(ids, has_children, collapsed)
|
||
}
|
||
|
||
#[test]
|
||
fn hovering_a_collapsed_parent_springs_it_open() {
|
||
// The point of the gesture: reaching a child of something closed.
|
||
let (ids, kids, collapsed) = spring_fixture();
|
||
assert_eq!(
|
||
should_spring(Some(0), &ids, &kids, &collapsed),
|
||
Some(CollectionId(1))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn hovering_an_already_open_parent_springs_nothing() {
|
||
// Its children are already reachable; rebuilding the tree would move
|
||
// rows under the pointer for no gain.
|
||
let (ids, kids, collapsed) = spring_fixture();
|
||
assert_eq!(should_spring(Some(1), &ids, &kids, &collapsed), None);
|
||
}
|
||
|
||
#[test]
|
||
fn hovering_a_leaf_springs_nothing() {
|
||
// A collection with no children has nothing to open, and flashing a
|
||
// rebuild would just shift the row the user is aiming at.
|
||
let (ids, kids, collapsed) = spring_fixture();
|
||
assert_eq!(should_spring(Some(2), &ids, &kids, &collapsed), None);
|
||
}
|
||
|
||
#[test]
|
||
fn hovering_nothing_springs_nothing() {
|
||
let (ids, kids, collapsed) = spring_fixture();
|
||
assert_eq!(should_spring(None, &ids, &kids, &collapsed), None);
|
||
}
|
||
|
||
#[test]
|
||
fn a_stale_row_index_springs_nothing() {
|
||
// The hover can outlive the row model it referred to.
|
||
let (ids, kids, collapsed) = spring_fixture();
|
||
assert_eq!(should_spring(Some(99), &ids, &kids, &collapsed), None);
|
||
}
|
||
|
||
#[test]
|
||
fn the_spring_dwell_is_long_enough_not_to_trigger_in_passing() {
|
||
// A tree that flaps open under every passing pointer is worse than one
|
||
// that never opens. This pins the intent rather than the number: a
|
||
// reflex-speed value here would be a regression, not a tuning choice.
|
||
// Asserted in a const item rather than at runtime: the condition is
|
||
// constant either way, and clippy is right that a runtime assert on it
|
||
// is theatre. This form fails the build instead of a test run, which is
|
||
// strictly earlier, and keeps the bound where a reader of the constant
|
||
// will look for it.
|
||
const _: () = assert!(
|
||
SPRING_DELAY_MS >= 300,
|
||
"a pointer crossing a parent must not open it"
|
||
);
|
||
const _: () = assert!(
|
||
SPRING_DELAY_MS <= 900,
|
||
"and a deliberate dwell must not feel like a hang"
|
||
);
|
||
}
|
||
}
|