//! 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::::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) } /// 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, 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::().set_library_held_row(row); if !ctl_cb.select_mode.get() { ctl_cb.select_mode.set(true); w.global::().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, row_ids: &[CollectionId], row_has_children: &[bool], collapsed: &std::collections::HashSet, ) -> Option { 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, catalog: &Rc>>, row: Option, ) { // 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, catalog: &Rc>>, keep: Option, ) { *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::::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 = (0..5).map(|_| thumb(64, 64)).collect(); let forty: Vec = (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, Vec, std::collections::HashSet, ) { 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" ); } }