Merge PR #2722: LR-VIEW-FILMSTRIP: photos drag from the filmstrip like from the grid

Conflicts in docs/parity-checklist.md docs/whats-new.md: kept both sides (union).
This commit is contained in:
aiongg
2026-10-31 02:07:01 +00:00
6 changed files with 204 additions and 7 deletions
+11 -1
View File
@@ -92,7 +92,7 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
if !local.intersects(vp.expand2(vec2(cell_w * 4.0, 0.0))) {
continue;
}
let resp = ui.interact(cr, egui::Id::unique(("film", id.0)), Sense::click());
let resp = ui.interact(cr, egui::Id::unique(("film", id.0)), Sense::click_and_drag());
let failed = app.renderer.thumb(*id).is_none() && app.renderer.failure(Slot::Thumb(*id)).is_some();
crate::widgets::announce(&resp, film_spoken(app.session.catalog.photo(*id).map_or("", |p| p.file_name.as_str()), failed));
register(ui, format!("film:{}", id.0), cr);
@@ -181,6 +181,16 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
app.toast(ui.ctx(), e);
}
}
// drag the selection to a sidebar row, as from the grid (a plain click never starts one); in
// Compare an unselected photo is dragged alone, so the pair and its active photo stay (#744).
// Only the primary button drags: a right-button drag must not replace the selection
if resp.drag_started_by(egui::PointerButton::Primary) {
if app.ui.view == crate::state::ViewMode::Compare && !app.session.selection.contains(*id) {
app.ui.dragging_photos = Some(vec![id.0]);
} else {
crate::panels::grid::begin_photo_drag(app, *id);
}
}
// the same photo actions as the grid and loupe (Restore / Delete Permanently in Recently Deleted)
crate::widgets::context_menu(&resp, |ui| crate::panels::grid::context_menu(app, ui, *id));
}
+10 -5
View File
@@ -979,12 +979,8 @@ fn cell_interaction(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Res
let _ = app.run("library.select", json!({"ids": [id.0]}));
let _ = app.run("view.detail", json!({}));
}
// drag the selection (this photo joins it, or replaces it when it wasn't selected)
if resp.drag_started() {
if !app.session.selection.contains(id) {
let _ = app.run("library.select", json!({"ids": [id.0]}));
}
app.ui.dragging_photos = Some(app.session.selection.ids.iter().map(|p| p.0).collect());
begin_photo_drag(app, id);
}
crate::widgets::context_menu(resp, |ui| context_menu(app, ui, id));
}
@@ -1115,6 +1111,15 @@ fn folder_breadcrumbs(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str) {
}
}
/// A drag of photo `id` began (grid cell or filmstrip cell): the photos it carries are the selection,
/// which this photo joins, or replaces when it wasn't part of it.
pub(crate) fn begin_photo_drag(app: &mut LightcraftApp, id: PhotoId) {
if !app.session.selection.contains(id) {
let _ = app.run("library.select", json!({"ids": [id.0]}));
}
app.ui.dragging_photos = Some(app.session.selection.ids.iter().map(|p| p.0).collect());
}
/// While photos are dragged: a badge at the pointer; the drag ends when the button is up
/// (drop targets act on the release frame, before this runs).
/// What the label following dragged photos says this frame instead of how many there are: where a
+1
View File
@@ -25,6 +25,7 @@ mod edit_layout;
mod empty_library;
mod error_kinds;
mod filmstrip;
mod filmstrip_drag;
mod focus_point;
mod folder_drop;
mod grading;
+173
View File
@@ -0,0 +1,173 @@
//! Photos dragged from the filmstrip carry what a drag from the grid carries (the selection, or just
//! the photo when it isn't selected), land on the same sidebar targets, and a plain click on a
//! filmstrip cell still only selects.
use std::time::Duration;
use egui::{Pos2, Rect};
use lightcraft_catalog::{AlbumId, PhotoId};
use serde_json::json;
use crate::headless::Headless;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
/// The demo library in the Detail view (filmstrip at the bottom), the Albums section on the left.
fn app() -> Headless {
let session = lightcraft_engine::Session::with_demo();
let mut h = Headless::new(LightcraftApp::new(session, Services { png: None, ..Default::default() }), [1300.0, 1000.0], 1.0);
let r = h.request("ui.set", json!({"view": "detail", "leftPanel": true, "sidebar": ["albums"]}), T);
assert_eq!(r["ok"], true, "{r}");
for _ in 0..6 {
h.step();
}
h
}
fn widget(h: &Headless, id: &str) -> Rect {
h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}"))
}
fn cell(h: &Headless, id: u64) -> Pos2 {
widget(h, &format!("film:{id}")).center()
}
fn button(h: &mut Headless, at: Pos2, pressed: bool) {
button_by(h, at, egui::PointerButton::Primary, pressed);
}
fn button_by(h: &mut Headless, at: Pos2, button: egui::PointerButton, pressed: bool) {
h.app.synthetic.push(egui::Event::PointerMoved(at));
h.step();
h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed, modifiers: egui::Modifiers::NONE });
h.step();
}
fn glide(h: &mut Headless, from: Pos2, to: Pos2) {
for i in 1..=8 {
h.app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
h.step();
}
}
fn select(h: &mut Headless, ids: &[u64]) {
h.app.run("library.select", json!({"ids": ids})).unwrap();
h.settle(Duration::from_secs(60));
}
fn selected(h: &Headless) -> Vec<u64> {
let mut v: Vec<u64> = h.app.session.selection.ids.iter().map(|p| p.0).collect();
v.sort_unstable();
v
}
fn sorted(mut v: Vec<u64>) -> Vec<u64> {
v.sort_unstable();
v
}
/// Three photos in the strip that are far enough left to be drawn, in strip order.
fn three(h: &mut Headless) -> [u64; 3] {
let v = h.app.session.visible_cloned();
[v[0].0, v[1].0, v[2].0]
}
#[test]
fn an_unselected_filmstrip_photo_drags_just_itself() {
let mut h = app();
let [a, b, c] = three(&mut h);
select(&mut h, &[a, b]);
let from = cell(&h, c);
button(&mut h, from, true);
glide(&mut h, from, from + egui::vec2(60.0, -200.0));
assert_eq!(h.app.ui.dragging_photos, Some(vec![c]), "the photo itself is dragged");
assert_eq!(selected(&h), vec![c], "and it replaced the selection, as in the grid");
}
/// Only the primary button drags: a right-button drag over an unselected cell leaves the selection
/// as it was and starts no drag.
#[test]
fn a_right_button_drag_on_a_filmstrip_cell_starts_no_photo_drag() {
let mut h = app();
let [a, b, c] = three(&mut h);
select(&mut h, &[a, b]);
let from = cell(&h, c);
button_by(&mut h, from, egui::PointerButton::Secondary, true);
glide(&mut h, from, from + egui::vec2(60.0, -200.0));
assert_eq!(h.app.ui.dragging_photos, None, "no photo drag from the secondary button");
assert_eq!(selected(&h), sorted(vec![a, b]), "the selection stays");
}
#[test]
fn a_selected_filmstrip_photo_drags_the_whole_selection() {
let mut h = app();
let [a, b, c] = three(&mut h);
select(&mut h, &[a, b, c]);
let from = cell(&h, b);
button(&mut h, from, true);
glide(&mut h, from, from + egui::vec2(60.0, -200.0));
let mut dragged = h.app.ui.dragging_photos.clone().expect("a drag");
dragged.sort_unstable();
assert_eq!(dragged, sorted(vec![a, b, c]));
assert_eq!(selected(&h), dragged, "the selection is untouched");
}
#[test]
fn a_click_on_a_filmstrip_cell_selects_and_is_not_a_drag() {
let mut h = app();
let [a, b, _] = three(&mut h);
select(&mut h, &[a]);
let at = cell(&h, b);
button(&mut h, at, true);
assert!(h.app.ui.dragging_photos.is_none(), "pressed: not yet a drag");
button(&mut h, at, false);
h.step();
assert!(h.app.ui.dragging_photos.is_none(), "released in place: no drag");
assert_eq!(selected(&h), vec![b], "the click selected the cell");
assert_eq!(h.app.session.selection.active, Some(PhotoId(b)), "and the loupe follows it");
}
#[test]
fn a_filmstrip_drag_dropped_on_an_album_adds_the_photos() {
let mut h = app();
let album = h.app.run("album.create", json!({"name": "Strip Target", "addSelected": false})).unwrap()["id"].as_u64().unwrap();
h.step();
h.step();
let [a, b, _] = three(&mut h);
select(&mut h, &[a, b]);
let row = widget(&h, &format!("source:album:{album}")).center();
let from = cell(&h, a);
button(&mut h, from, true);
glide(&mut h, from, row);
assert!(h.app.ui.dragging_photos.is_some(), "the drag is on");
button(&mut h, row, false);
h.step();
let mut members: Vec<u64> = h.app.session.catalog.album(AlbumId(album)).unwrap().photos.iter().map(|p| p.0).collect();
members.sort_unstable();
assert_eq!(members, sorted(vec![a, b]));
assert!(h.app.ui.dragging_photos.is_none(), "the drag ended");
}
/// In Compare, the active photo is always one of the two shown: dragging an unselected photo from
/// the strip carries it alone and leaves the selection, the active photo and the pair as they were.
#[test]
fn a_filmstrip_drag_in_compare_keeps_the_active_photo_on_screen() {
let mut h = app();
let vis: Vec<u64> = h.app.session.visible_cloned().iter().map(|p| p.0).collect();
h.app.run("library.select", json!({"ids": [vis[0], vis[1]], "active": vis[0]})).unwrap();
h.app.run("view.compare", json!({})).unwrap();
h.settle(Duration::from_secs(60));
let (sel, cand) = h.app.ui.compare.expect("comparing");
let before = selected(&h);
let active_before = h.app.session.selection.active.map(|p| p.0);
let from = cell(&h, vis[4]);
button(&mut h, from, true);
glide(&mut h, from, from + egui::vec2(60.0, -200.0));
assert_eq!(h.app.ui.dragging_photos, Some(vec![vis[4]]), "the photo is dragged");
assert_eq!(selected(&h), before, "the selection stays");
let active = h.app.session.selection.active.map(|p| p.0).expect("an active photo");
assert_eq!(Some(active), active_before, "the active photo is unchanged");
assert!(active == sel || active == cand, "active {active} is on screen ({sel} | {cand})");
assert_eq!(h.app.ui.compare, Some((sel, cand)), "the pair stays");
}
File diff suppressed because one or more lines are too long
+6
View File
@@ -95,6 +95,12 @@
### Editing a smart album made from an album keeps the album
- A smart album saved from the view of an album stays limited to that album when you edit its rules and press OK. It used to match the whole library afterwards, because the rules dialog has no album field and replaced the album along with the rules.
### Drag photos from the filmstrip
- You can now drag a photo from the filmstrip onto the sidebar, as you can from the grid: onto an
album, a folder or a keyword. Dragging a selected photo takes the whole selection; dragging one that isn't selected
takes just that photo and selects it. In the Compare view, a photo that isn't selected is dragged alone and the
selection, the active photo and the two photos on screen stay as they were. Clicking a filmstrip photo works as
before: outside Compare it selects the photo, and in Compare a plain click puts the photo into the active side.
## 2026-10-22