mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 05:57:09 -06:00
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:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user