From 7988024c2482f0b5c4d2aff7ad93c92d1c46f4fa Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 23 Oct 2026 13:20:02 -0400 Subject: [PATCH 1/3] Filmstrip: photos drag from the filmstrip like from the grid --- crates/ui-egui/src/panels/detail/filmstrip.rs | 12 +- crates/ui-egui/src/panels/grid.rs | 15 +- crates/ui-egui/src/tests.rs | 1 + crates/ui-egui/src/tests/filmstrip_drag.rs | 171 ++++++++++++++++++ docs/parity-checklist.md | 4 +- docs/whats-new.md | 5 + 6 files changed, 200 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/tests/filmstrip_drag.rs diff --git a/crates/ui-egui/src/panels/detail/filmstrip.rs b/crates/ui-egui/src/panels/detail/filmstrip.rs index c39fc4442..b18c42773 100644 --- a/crates/ui-egui/src/panels/detail/filmstrip.rs +++ b/crates/ui-egui/src/panels/detail/filmstrip.rs @@ -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); @@ -186,6 +186,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)); } diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 262979cc5..3bce187f2 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -943,12 +943,8 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square 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)); } @@ -1066,6 +1062,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 diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 3a102a29a..06b9ba11e 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -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; diff --git a/crates/ui-egui/src/tests/filmstrip_drag.rs b/crates/ui-egui/src/tests/filmstrip_drag.rs new file mode 100644 index 000000000..86c58d44e --- /dev/null +++ b/crates/ui-egui/src/tests/filmstrip_drag.rs @@ -0,0 +1,171 @@ +//! 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 { + let mut v: Vec = h.app.session.selection.ids.iter().map(|p| p.0).collect(); + v.sort_unstable(); + v +} + +fn sorted(mut v: Vec) -> Vec { + 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 = 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 = 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 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!(active == sel || active == cand, "active {active} is on screen ({sel} | {cand})"); + assert_eq!(h.app.ui.compare, Some((sel, cand)), "the pair stays"); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 90cfc248f..22dfce4f7 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -128,7 +128,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | a “Rendering…” pill at the bottom of the photo (widget `loupe:rendering`; above the Navigator when that is in the way) once a render has been on its way for 0.5 s with nothing new on screen: over a stand-in (the raw's embedded JPEG, a thumbnail), the previous render after an edit, or, zoomed in, the magnified frame waiting for its window; Before views watch their own renders. Always on (Lightroom Classic makes it a View Options choice). When the render fails behind a stand-in, a caution pill says “This photo can't be opened” (“Original offline · no smart preview” when that is why) with the reason on hover and for screen readers (widget `loupe:renderFailed`; `ui.inspect` → `loupe.failed`) | | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | -| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | +| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid; a drag from a cell carries the selection (a photo outside it drags alone and replaces it) to the same sidebar rows as a drag from the grid, and a click without movement still selects | | 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; in Detail a middle-button drag (the wheel pressed) pans with a hand cursor whatever tool is active, so Remove / Heal / Clone, mask brushes and Crop don't paint or move meanwhile (`middle_pan`, `tests_canvas_pan.rs`); in Detail a middle-button drag (the wheel pressed) pans with a hand cursor whatever tool is active, so Remove / Heal / Clone, mask brushes and Crop don't paint or move meanwhile (`middle_pan`, `tests_canvas_pan.rs`); 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 slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with an output-device profile proof or the HDR range shown, or for a control whose full-size drafts on this photo went clearly over the 16 ms slider budget (more than 20 ms) in two drags in a row (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it, and a host frame time over 1 s (a hidden or stalled window) counts as unknown. A slow verdict is per photo and control (the same control on another photo, or another control on this one, is timed afresh) and expires: after 30 s a drag tries full size again, the wait doubling after each slow retry up to 240 s. Only a drag timed at full size changes it: one whose full-size drafts' median time (leaving out the first) is at most 20 ms forgets it; a drag that drafts small from its start (while the verdict holds, or where the GPU does not draw the view) or times fewer than two full-size drafts leaves it unchanged. At most 256 (photo, control) pairs are remembered, the oldest forgotten first. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) and the size the fit view is shrunk to 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. Diagnostic overlays (Visualize Spots, Point Color range, HDR range) get windows too, tested equal to the whole-frame render's pixels. 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, 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. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; only an output-device-profile proof renders on the CPU, where a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with an output-device-profile soft proof (the CPU path) | | LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests/focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests/focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | 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 | @@ -958,4 +958,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-22 | minor | MENU-BAR: on macOS the native menu gives a key back to the window while a text field has focus or a shortcut is being recorded (muda's `set_accelerator(None)` does nothing there, so the menu item is now swapped for one without a key, and the key is put back on that item afterwards); status unchanged (✅) | | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | -| 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) | +| 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) LR-VIEW-FILMSTRIP: filmstrip cells drag photos with the grid's drag (selection, drop targets, count label); a click still selects. Statuses and feature counts unchanged. | diff --git a/docs/whats-new.md b/docs/whats-new.md index c6c259200..7814ec612 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -82,6 +82,11 @@ library you open and are imported into it, as files given on the command line are; presets dropped then are refused with a note, since they belong to a library. +### Drag photos from the filmstrip +- In the Detail view 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. A click on a filmstrip photo still only selects it. + ## 2026-10-22 ### Reliability From a90c4a200b1e96862c60c98269645a0359731b6d Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 23 Oct 2026 14:18:56 -0400 Subject: [PATCH 2/3] Filmstrip drag: state the Compare exception in the docs; the Compare test pins the active photo --- crates/ui-egui/src/tests/filmstrip_drag.rs | 2 ++ docs/parity-checklist.md | 4 ++-- docs/whats-new.md | 6 ++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/tests/filmstrip_drag.rs b/crates/ui-egui/src/tests/filmstrip_drag.rs index 86c58d44e..da2e7c3f0 100644 --- a/crates/ui-egui/src/tests/filmstrip_drag.rs +++ b/crates/ui-egui/src/tests/filmstrip_drag.rs @@ -160,12 +160,14 @@ fn a_filmstrip_drag_in_compare_keeps_the_active_photo_on_screen() { 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"); } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 22dfce4f7..40a724443 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -128,7 +128,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | a “Rendering…” pill at the bottom of the photo (widget `loupe:rendering`; above the Navigator when that is in the way) once a render has been on its way for 0.5 s with nothing new on screen: over a stand-in (the raw's embedded JPEG, a thumbnail), the previous render after an edit, or, zoomed in, the magnified frame waiting for its window; Before views watch their own renders. Always on (Lightroom Classic makes it a View Options choice). When the render fails behind a stand-in, a caution pill says “This photo can't be opened” (“Original offline · no smart preview” when that is why) with the reason on hover and for screen readers (widget `loupe:renderFailed`; `ui.inspect` → `loupe.failed`) | | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | -| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid; a drag from a cell carries the selection (a photo outside it drags alone and replaces it) to the same sidebar rows as a drag from the grid, and a click without movement still selects | +| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid; a drag from a cell carries the selection (a photo outside it drags alone and replaces it; in Compare it drags alone and leaves the selection, the active photo and the pair as they are) to the same sidebar rows as a drag from the grid, and a click without movement still selects | | 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; in Detail a middle-button drag (the wheel pressed) pans with a hand cursor whatever tool is active, so Remove / Heal / Clone, mask brushes and Crop don't paint or move meanwhile (`middle_pan`, `tests_canvas_pan.rs`); in Detail a middle-button drag (the wheel pressed) pans with a hand cursor whatever tool is active, so Remove / Heal / Clone, mask brushes and Crop don't paint or move meanwhile (`middle_pan`, `tests_canvas_pan.rs`); 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 slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with an output-device profile proof or the HDR range shown, or for a control whose full-size drafts on this photo went clearly over the 16 ms slider budget (more than 20 ms) in two drags in a row (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it, and a host frame time over 1 s (a hidden or stalled window) counts as unknown. A slow verdict is per photo and control (the same control on another photo, or another control on this one, is timed afresh) and expires: after 30 s a drag tries full size again, the wait doubling after each slow retry up to 240 s. Only a drag timed at full size changes it: one whose full-size drafts' median time (leaving out the first) is at most 20 ms forgets it; a drag that drafts small from its start (while the verdict holds, or where the GPU does not draw the view) or times fewer than two full-size drafts leaves it unchanged. At most 256 (photo, control) pairs are remembered, the oldest forgotten first. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) and the size the fit view is shrunk to 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. Diagnostic overlays (Visualize Spots, Point Color range, HDR range) get windows too, tested equal to the whole-frame render's pixels. 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, 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. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; only an output-device-profile proof renders on the CPU, where a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with an output-device-profile soft proof (the CPU path) | | LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests/focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests/focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | 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 | @@ -958,4 +958,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-22 | minor | MENU-BAR: on macOS the native menu gives a key back to the window while a text field has focus or a shortcut is being recorded (muda's `set_accelerator(None)` does nothing there, so the menu item is now swapped for one without a key, and the key is put back on that item afterwards); status unchanged (✅) | | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | -| 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) LR-VIEW-FILMSTRIP: filmstrip cells drag photos with the grid's drag (selection, drop targets, count label); a click still selects. Statuses and feature counts unchanged. | +| 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) LR-VIEW-FILMSTRIP: filmstrip cells drag photos with the grid's drag (selection, drop targets, count label; in Compare an unselected photo drags alone); a click still selects. Statuses and feature counts unchanged. | diff --git a/docs/whats-new.md b/docs/whats-new.md index 7814ec612..572f9fd7b 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -83,9 +83,11 @@ refused with a note, since they belong to a library. ### Drag photos from the filmstrip -- In the Detail view you can now drag a photo from the filmstrip onto the sidebar, as you can from the grid: onto an +- 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. A click on a filmstrip photo still only selects it. + 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. A click on a filmstrip photo still only + selects it. ## 2026-10-22 From 4ee7cd5f7e133a7767bee383f19bafa1a5a9f2b4 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 23 Oct 2026 15:05:31 -0400 Subject: [PATCH 3/3] Filmstrip drag docs: a click in Compare goes to the active side, as before --- docs/parity-checklist.md | 7 ++++--- docs/whats-new.md | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 40a724443..53db7b3d3 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-VIEW-FILMSTRIP: photos drag from the filmstrip to the same sidebar targets as from the grid; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -128,7 +128,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | a “Rendering…” pill at the bottom of the photo (widget `loupe:rendering`; above the Navigator when that is in the way) once a render has been on its way for 0.5 s with nothing new on screen: over a stand-in (the raw's embedded JPEG, a thumbnail), the previous render after an edit, or, zoomed in, the magnified frame waiting for its window; Before views watch their own renders. Always on (Lightroom Classic makes it a View Options choice). When the render fails behind a stand-in, a caution pill says “This photo can't be opened” (“Original offline · no smart preview” when that is why) with the reason on hover and for screen readers (widget `loupe:renderFailed`; `ui.inspect` → `loupe.failed`) | | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | -| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid; a drag from a cell carries the selection (a photo outside it drags alone and replaces it; in Compare it drags alone and leaves the selection, the active photo and the pair as they are) to the same sidebar rows as a drag from the grid, and a click without movement still selects | +| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid; a drag from a cell carries the selection (a photo outside it drags alone and replaces it; in Compare it drags alone and leaves the selection, the active photo and the pair as they are) to the same sidebar rows as a drag from the grid, and a click without movement works as before (it selects the photo; in Compare it goes to the active side, see LR-VIEW-COMPARE) | | 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; in Detail a middle-button drag (the wheel pressed) pans with a hand cursor whatever tool is active, so Remove / Heal / Clone, mask brushes and Crop don't paint or move meanwhile (`middle_pan`, `tests_canvas_pan.rs`); in Detail a middle-button drag (the wheel pressed) pans with a hand cursor whatever tool is active, so Remove / Heal / Clone, mask brushes and Crop don't paint or move meanwhile (`middle_pan`, `tests_canvas_pan.rs`); 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 slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with an output-device profile proof or the HDR range shown, or for a control whose full-size drafts on this photo went clearly over the 16 ms slider budget (more than 20 ms) in two drags in a row (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it, and a host frame time over 1 s (a hidden or stalled window) counts as unknown. A slow verdict is per photo and control (the same control on another photo, or another control on this one, is timed afresh) and expires: after 30 s a drag tries full size again, the wait doubling after each slow retry up to 240 s. Only a drag timed at full size changes it: one whose full-size drafts' median time (leaving out the first) is at most 20 ms forgets it; a drag that drafts small from its start (while the verdict holds, or where the GPU does not draw the view) or times fewer than two full-size drafts leaves it unchanged. At most 256 (photo, control) pairs are remembered, the oldest forgotten first. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) and the size the fit view is shrunk to 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. Diagnostic overlays (Visualize Spots, Point Color range, HDR range) get windows too, tested equal to the whole-frame render's pixels. 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, 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. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; only an output-device-profile proof renders on the CPU, where a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with an output-device-profile soft proof (the CPU path) | | LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests/focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests/focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | 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 | @@ -958,4 +958,5 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-22 | minor | MENU-BAR: on macOS the native menu gives a key back to the window while a text field has focus or a shortcut is being recorded (muda's `set_accelerator(None)` does nothing there, so the menu item is now swapped for one without a key, and the key is put back on that item afterwards); status unchanged (✅) | | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | -| 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) LR-VIEW-FILMSTRIP: filmstrip cells drag photos with the grid's drag (selection, drop targets, count label; in Compare an unselected photo drags alone); a click still selects. Statuses and feature counts unchanged. | +| 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) | +| 2026-10-23 | patch | LR-VIEW-FILMSTRIP: filmstrip cells drag photos with the grid's drag (selection, drop targets, count label; in Compare an unselected photo drags alone); a click works as before (in Compare it goes to the active side). Statuses and feature counts unchanged. | diff --git a/docs/whats-new.md b/docs/whats-new.md index 572f9fd7b..06b5b4d3f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -86,8 +86,8 @@ - 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. A click on a filmstrip photo still only - selects it. + 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