mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
LR-VIEW-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip select in Compare
In Compare a filmstrip click goes to `compare.set` (#747), so the grid's modifier clicks never reached `library.select` and multi-select from the filmstrip was impossible there (#187 follow-up). A ⌘/Ctrl-click now toggles the photo and a Shift-click extends from the active photo, as in the grid; the two photos shown stay selected and the active photo is unchanged, so a rating key still acts on a photo that is on screen. A plain click still replaces the active side. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01NH3itd85jPuM6wNjgcGfEH
This commit is contained in:
1 parent
b3d392b499
commit
e935ad75e2
4 files changed
+125
-15
No files matched your search
@@ -97,6 +97,31 @@ pub fn set_photo(app: &mut LightcraftApp, id: PhotoId, side: Option<&str>) -> Re
|
||||
Ok(json!({"select": a.0, "candidate": b.0}))
|
||||
}
|
||||
|
||||
/// A ⌘/Ctrl-click (`range: false`, toggles the photo) or Shift-click (`range: true`, extends
|
||||
/// from the active photo) on the filmstrip in Compare: the selection changes as it does in the
|
||||
/// grid, but the two photos shown stay selected and the active one stays active, so the photo the
|
||||
/// rating keys act on is still on screen (#744). A plain click is [`set_photo`].
|
||||
pub fn select_with_modifier(app: &mut LightcraftApp, id: PhotoId, range: bool) -> Result<Value, String> {
|
||||
if app.session.catalog.photo(id).is_none_or(|p| p.deleted) {
|
||||
return Err(format!("no photo {}", id.0));
|
||||
}
|
||||
let (sel, cand) = compare_pair(app).ok_or("nothing to compare")?;
|
||||
let mut next = app.session.selection.clone();
|
||||
if range {
|
||||
next.extend_to(id, &app.session.visible_cloned());
|
||||
} else {
|
||||
next.toggle(id);
|
||||
}
|
||||
for shown in [sel, cand] {
|
||||
if !next.contains(shown) {
|
||||
next.ids.push(shown);
|
||||
}
|
||||
}
|
||||
let active = app.session.selection.active.filter(|a| next.contains(*a)).unwrap_or(cand);
|
||||
let ids: Vec<u64> = next.ids.iter().map(|p| p.0).collect();
|
||||
app.session.execute("library.select", &json!({"ids": ids, "active": active.0})).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The candidate becomes the select; the next photo becomes the candidate.
|
||||
pub fn make_select(app: &mut LightcraftApp) -> Result<Value, String> {
|
||||
let (_, b) = compare_pair(app).ok_or("nothing to compare")?;
|
||||
|
||||
@@ -2333,21 +2333,28 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
|
||||
p.galley(pos2(br.min.x + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE);
|
||||
}
|
||||
}
|
||||
if resp.clicked() && app.ui.view == crate::state::ViewMode::Compare {
|
||||
// Compare: the clicked photo replaces the active side (or swaps with the other one)
|
||||
if let Err(e) = app.run("compare.set", json!({"id": id.0})) {
|
||||
if resp.clicked() {
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
let compare = app.ui.view == crate::state::ViewMode::Compare;
|
||||
let r = if compare && !m.command && !m.shift {
|
||||
// Compare: the clicked photo replaces the active side (or swaps with the other one)
|
||||
app.run("compare.set", json!({"id": id.0}))
|
||||
} else if compare {
|
||||
// ⌘/Ctrl- and Shift-clicks change the selection; the pair on screen stays (#187)
|
||||
super::compare::select_with_modifier(app, *id, !m.command)
|
||||
} else {
|
||||
let mode = if m.command {
|
||||
"toggle"
|
||||
} else if m.shift {
|
||||
"range"
|
||||
} else {
|
||||
"replace"
|
||||
};
|
||||
app.run("library.select", json!({"ids": [id.0], "mode": mode}))
|
||||
};
|
||||
if compare && let Err(e) = r {
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
} else if resp.clicked() {
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
let mode = if m.command {
|
||||
"toggle"
|
||||
} else if m.shift {
|
||||
"range"
|
||||
} else {
|
||||
"replace"
|
||||
};
|
||||
let _ = app.run("library.select", json!({"ids": [id.0], "mode": mode}));
|
||||
}
|
||||
// the same photo actions as the grid and loupe (Restore / Delete Permanently in Recently Deleted)
|
||||
resp.context_menu(|ui| super::grid::context_menu(app, ui, *id));
|
||||
|
||||
@@ -83,6 +83,83 @@ fn filmstrip_click_replaces_the_active_side_in_compare() {
|
||||
assert_eq!(r["ok"], false, "unknown side is an error: {r}");
|
||||
}
|
||||
|
||||
/// A ⌘/Ctrl-click or Shift-click on the filmstrip in Compare changes the selection as it does in
|
||||
/// the grid (#187): the photo is toggled, or the range from the active photo is added. The two
|
||||
/// photos shown stay selected and the active one stays active, so a rating key still acts on a
|
||||
/// photo that is on screen (#744).
|
||||
#[test]
|
||||
fn modified_filmstrip_clicks_in_compare_change_the_selection_not_the_pair() {
|
||||
let mut h = demo();
|
||||
let vis: Vec<u64> = h.app.session.visible_cloned().iter().map(|p| p.0).collect();
|
||||
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0], vis[1]], "active": vis[0]}}), T);
|
||||
let r = h.request("engine.execute", json!({"command": "view.compare"}), T);
|
||||
let (sel, cand) = (r["result"]["select"].as_u64().unwrap(), r["result"]["candidate"].as_u64().unwrap());
|
||||
h.settle(SETTLE);
|
||||
let click_film = |h: &mut Headless, id: u64, cmd: bool, shift: bool| {
|
||||
let r = h.request("ui.clickWidget", json!({"id": format!("film:{id}"), "cmd": cmd, "shift": shift}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
};
|
||||
let selected = |h: &Headless| -> Vec<u64> {
|
||||
let s = &h.app.session.selection;
|
||||
vis.iter().copied().filter(|v| s.contains(lightcraft_catalog::PhotoId(*v))).collect()
|
||||
};
|
||||
let active = |h: &Headless| h.app.session.selection.active.map(|p| p.0);
|
||||
let rating = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).map(|p| p.rating);
|
||||
let others: Vec<u64> = vis.iter().copied().filter(|v| *v != sel && *v != cand).collect();
|
||||
assert!(others.len() >= 4, "the demo library has {} photos", vis.len());
|
||||
let before = active(&h);
|
||||
// ⌘-click: a third photo joins the selection; the pair and the active photo stay
|
||||
click_film(&mut h, others[0], true, false);
|
||||
assert_eq!(selected(&h), {
|
||||
let mut v = vec![sel, cand, others[0]];
|
||||
v.sort_by_key(|id| vis.iter().position(|v| v == id));
|
||||
v
|
||||
});
|
||||
assert_eq!(h.app.ui.compare, Some((sel, cand)), "the pair is untouched");
|
||||
assert_eq!(active(&h), before, "the active photo is untouched");
|
||||
// ⌘-click again: it leaves
|
||||
click_film(&mut h, others[0], true, false);
|
||||
assert_eq!(selected(&h), {
|
||||
let mut v = vec![sel, cand];
|
||||
v.sort_by_key(|id| vis.iter().position(|v| v == id));
|
||||
v
|
||||
});
|
||||
assert_eq!(h.app.ui.compare, Some((sel, cand)));
|
||||
assert_eq!(active(&h), before);
|
||||
// Shift-click: everything from the active photo to the clicked one is selected
|
||||
let far = others[3];
|
||||
click_film(&mut h, far, false, true);
|
||||
let (a, b) = (vis.iter().position(|v| Some(*v) == before).unwrap(), vis.iter().position(|v| *v == far).unwrap());
|
||||
let now = selected(&h);
|
||||
for id in &vis[a.min(b)..=a.max(b)] {
|
||||
assert!(now.contains(id), "{id} is in the range {before:?}..{far}: {now:?}");
|
||||
}
|
||||
assert!(now.contains(&sel) && now.contains(&cand), "the pair stays selected: {now:?}");
|
||||
assert_eq!(h.app.ui.compare, Some((sel, cand)));
|
||||
assert_eq!(active(&h), before, "Shift-click does not move the active photo in Compare");
|
||||
// ⌘-click on a photo that is on screen: it cannot leave the selection while it is compared
|
||||
click_film(&mut h, sel, true, false);
|
||||
assert!(selected(&h).contains(&sel), "the select stays selected");
|
||||
assert_eq!(h.app.ui.compare, Some((sel, cand)));
|
||||
assert_eq!(active(&h), before);
|
||||
// ⌘-click on the active photo: `toggle` would hand "active" to another photo; it stays put
|
||||
click_film(&mut h, before.unwrap(), true, false);
|
||||
assert!(selected(&h).contains(&before.unwrap()), "the active photo stays selected");
|
||||
assert_eq!(h.app.ui.compare, Some((sel, cand)));
|
||||
assert_eq!(active(&h), before);
|
||||
// a rating key acts on the active photo, which is on screen; a merely selected photo is left alone
|
||||
let was = rating(&h, far);
|
||||
h.request("ui.key", json!({"key": "3"}), T);
|
||||
h.settle(SETTLE);
|
||||
assert_eq!(rating(&h, before.unwrap()), Some(3));
|
||||
assert_eq!(rating(&h, far), was, "the hidden photo was rated");
|
||||
// a plain click still replaces the active side
|
||||
click_film(&mut h, others[1], false, false);
|
||||
assert_ne!(h.app.ui.compare, Some((sel, cand)));
|
||||
assert!(h.app.ui.compare.is_some_and(|(s, c)| s == others[1] || c == others[1]));
|
||||
}
|
||||
|
||||
/// Compare is where sharpness is judged: while a pane's render of an edit is on its way, that pane
|
||||
/// says "Rendering…" once it has lasted a moment, and stops when the render lands.
|
||||
#[test]
|
||||
|
||||
@@ -132,7 +132,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) |
|
||||
| LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans |
|
||||
| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it |
|
||||
| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) as the loupe does |
|
||||
| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; a ⌘/Ctrl- or Shift-click on the filmstrip changes the selection as in the grid while the two photos shown stay selected and the active photo is unchanged (#187); 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) as the loupe does |
|
||||
| LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes |
|
||||
| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) |
|
||||
| LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen, every 4 s (`interval`), wrapping; Space pauses, ←/→ step, Esc ends |
|
||||
@@ -687,7 +687,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
||||
| LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ✅ | `cmd:library.autoImport` | see LR-IMP-AUTO |
|
||||
| LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | | |
|
||||
| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info; no people view; picking one in Photo Grid switches to Square Grid) |
|
||||
| LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; a filmstrip click replaces the active side; zoom always linked; each pane says “Rendering…” or that the photo can't be opened, as the loupe does (see LR-VIEW-COMPARE) |
|
||||
| LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; a filmstrip click replaces the active side, a ⌘/Ctrl- or Shift-click changes the selection without touching the pair; zoom always linked; each pane says “Rendering…” or that the photo can't be opened, as the loupe does (see LR-VIEW-COMPARE) |
|
||||
| LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N |
|
||||
| LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo; each pane says “Rendering…” (widget `compare:rendering:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) as the loupe does |
|
||||
| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) |
|
||||
@@ -789,6 +789,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged |
|
||||
| 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged |
|
||||
| 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) |
|
||||
| 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) |
|
||||
|
||||
Reference in new issue
Block a user