Files

587 lines
22 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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"
);
}
}