Merge PR: integration batch 46 (6 community PRs)

Integration batch 46 (6 community PRs)
This commit is contained in:
Brandon Thomas
2026-11-01 22:39:34 -05:00
committed by GitHub
10 changed files with 196 additions and 20 deletions
+17 -1
View File
@@ -157,7 +157,10 @@ impl CopyrightStatus {
pub fn parse_shutter_seconds(s: &str) -> Option<f64> {
let s = s.trim().trim_end_matches(['s', '"']).trim();
match s.split_once('/') {
Some((n, d)) => Some(n.trim().parse::<f64>().ok()? / d.trim().parse::<f64>().ok()?),
Some((n, d)) => {
let (n, d) = (n.trim().parse::<f64>().ok()?, d.trim().parse::<f64>().ok()?);
if n > 0.0 && d > 0.0 { Some(n / d) } else { None }
}
None => s.parse().ok(),
}
.filter(|v: &f64| v.is_finite() && *v > 0.0)
@@ -716,3 +719,16 @@ mod edited_tests {
}
}
}
#[cfg(test)]
mod shutter_tests {
use super::parse_shutter_seconds;
#[test]
fn a_fraction_needs_a_positive_numerator_and_denominator() {
for s in ["-1/-250", "1/-250", "-1/250", "0/250"] {
assert_eq!(parse_shutter_seconds(s), None, "{s}");
}
assert_eq!(parse_shutter_seconds("1/250"), Some(0.004));
}
}
+1 -1
View File
@@ -226,7 +226,7 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
"person" | "who" => has_person(p, val),
"type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val),
"edited" => (val == "true" || val == "yes") == p.is_edited(),
"date" => p.captured.as_deref().is_some_and(|c| c.starts_with(val)),
"date" => p.captured.as_deref().is_some_and(|c| c.get(..val.len()).is_some_and(|h| h.eq_ignore_ascii_case(val))),
"copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(),
"name" | "file" => p.file_name.to_lowercase().contains(val),
// Not a known field: the colon is part of a plain word ("10:30", "Re:"), its value unquoted
+11
View File
@@ -12,6 +12,17 @@ fn photo(c: &mut Catalog, name: &str, date: &str) -> PhotoId {
id
}
#[test]
fn date_token_matches_time_part_case_insensitively() {
let mut c = Catalog::new();
let a = photo(&mut c, "a.jpg", "2026-05-01T10:00:00");
let q = |c: &Catalog, text: &str| c.query(&Filter { text: text.into(), ..Default::default() }, &Sort::default());
assert_eq!(q(&c, "date:2026-05-01T10"), vec![a]);
assert_eq!(q(&c, "date:2026-05-01t10"), vec![a]);
assert_eq!(q(&c, "date:2026-05"), vec![a]);
assert!(q(&c, "date:2026-05-01T11").is_empty());
}
#[test]
fn apply_and_inverse_roundtrip() {
let mut c = Catalog::new();
+34 -4
View File
@@ -386,7 +386,8 @@ fn module_source(m: &Module, consts: &str) -> String {
}
impl Gpu {
/// Create a device on the best available adapter (no software fallback), or why there is none.
/// Create a device on the best available adapter (no software adapter unless
/// `LIGHTCRAFT_GPU_ALLOW_SOFTWARE`), or why there is none.
pub fn new(backends: wgpu::Backends) -> Result<Gpu, String> {
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
// only these drivers are loaded (`crate::backend`: never Vulkan on Windows unless asked)
@@ -401,10 +402,13 @@ impl Gpu {
)
.map_err(|e| format!("no GPU adapter among {backends:?} ({e})"))?;
let info = adapter.get_info();
if info.device_type == wgpu::DeviceType::Cpu {
if refuse_adapter(info.device_type, software_allowed()) {
log::info!("gpu: only a software adapter ({}), using the CPU pipeline", info.name);
return Err(format!("software adapter ({}) skipped: the CPU pipeline is faster", info.name));
}
if info.device_type == wgpu::DeviceType::Cpu {
log::info!("gpu: LIGHTCRAFT_GPU_ALLOW_SOFTWARE: using the software adapter {}", info.name);
}
let mut limits = adapter.limits();
if limits.max_storage_buffers_per_shader_stage < 10 {
log::info!("gpu: {} has too few storage buffers per stage", info.name);
@@ -747,6 +751,19 @@ impl Gpu {
/// normally reset a hung GPU much sooner).
const READBACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
/// Whether to turn down an adapter of `device_type`: a software rasterizer (llvmpipe / lavapipe,
/// WARP) is slower than the CPU pipeline, so it is refused unless `allow_software`.
fn refuse_adapter(device_type: wgpu::DeviceType, allow_software: bool) -> bool {
device_type == wgpu::DeviceType::Cpu && !allow_software
}
/// `LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1`: accept a software adapter. For tests on machines without a
/// GPU (Mesa lavapipe on Linux, WARP on Windows), so the CPU ↔ GPU equivalence tests run there
/// instead of skipping; the app never sets it (software rendering is slower than the CPU pipeline).
fn software_allowed() -> bool {
std::env::var("LIGHTCRAFT_GPU_ALLOW_SOFTWARE").is_ok_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
}
/// `LIGHTCRAFT_GPU_LIMITS`: lower the device limits, e.g. to reproduce a smaller adapter.
/// `webgpu` / `downlevel` = 128 MiB storage bindings and 256 MiB buffers (the WebGPU defaults),
/// `<n>` = n MiB storage bindings and 2n MiB buffers. Returns (binding bytes, buffer bytes).
@@ -764,8 +781,8 @@ fn parse_limits(v: &str) -> Option<(u64, u64)> {
}
n => {
let mb: u64 = n.trim_end_matches("mib").trim_end_matches("mb").trim().parse().ok()?;
let b = mb.max(1) << 20;
Some((b, 2 * b))
let b = mb.max(1).checked_mul(1 << 20)?;
Some((b, b.checked_mul(2)?))
}
}
}
@@ -906,6 +923,8 @@ mod tests {
assert_eq!(super::parse_limits("512MiB"), Some((512 << 20, 1024 << 20)));
assert_eq!(super::parse_limits(""), None);
assert_eq!(super::parse_limits("lots"), None);
assert_eq!(super::parse_limits("9223372036854775807"), None);
assert_eq!(super::parse_limits("17592186044416"), None);
}
/// Issue #2936: the kernels are built on several threads; every entry point of every module has one.
@@ -1151,6 +1170,17 @@ mod tests {
println!("CHILD: survived");
}
#[test]
fn software_adapters_need_the_opt_in() {
use wgpu::DeviceType::{Cpu, DiscreteGpu, IntegratedGpu, Other, VirtualGpu};
assert!(super::refuse_adapter(Cpu, false), "llvmpipe / WARP are refused by default");
assert!(!super::refuse_adapter(Cpu, true), "LIGHTCRAFT_GPU_ALLOW_SOFTWARE accepts them");
for t in [DiscreteGpu, IntegratedGpu, VirtualGpu, Other] {
assert!(!super::refuse_adapter(t, false), "{t:?}");
assert!(!super::refuse_adapter(t, true), "{t:?}");
}
}
#[test]
fn dispatches_stay_within_the_workgroup_limit() {
// 1-D kernels over a 100 MP image and 2-D ones over 16k × 16k stay under 65535 per axis
+19 -11
View File
@@ -2,7 +2,8 @@
//! `lightcraft_gpu::render`, compared in 8-bit sRGB.
//!
//! Bounds (documented in `docs/gpu-pipeline.md`): mean |Δ| < 0.5 LSB and max |Δ| ≤ 3 LSB per
//! channel for every case. Skips (passes with a note) when no GPU adapter exists, e.g. on CI.
//! channel for every case. Skips (passes with a note) when no GPU adapter exists;
//! `LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1` runs them on a software adapter (lavapipe, WARP) instead.
use std::sync::Arc;
@@ -889,13 +890,20 @@ fn windows_match() {
check("window: vignette corner", &src, &info, &s, &corner);
}
/// Issue #323: a window of a 24 MP photo is cut from a source of 288 MB on the device. Keeping that
/// upload with the view's stages counted it against the stage budget on every tick of a drag and
/// got the stages cleared, and the upload and resampling redone: the window's stages are its own
/// pixels, not the photo's.
/// Issue #323: a window of a big photo is cut from the whole source on the device (288 MB for 24 MP).
/// Keeping that upload with the view's stages counted it against the stage budget on every tick of
/// a drag and got the stages cleared, and the upload and resampling redone: the window's stages are
/// its own pixels, not the photo's.
// (these two share the process's one big source: a test lock keeps them from replacing each other's)
static BIG_SOURCE_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// The big photo of these tests: 10.24 MP, a 117 MiB source. That is over what a view keeps with its
/// stages (96 MiB, so it is shared) and within a 128 MiB storage binding (the WebGPU default, and
/// the limit of Mesa's lavapipe), so the source goes to the device on every adapter. A source
/// over the device's limit is resampled on the CPU instead and never uploaded, which would leave
/// these tests nothing to check.
const BIG: usize = 3200;
#[test]
fn a_window_does_not_keep_the_whole_source_on_the_device() {
let _lock = BIG_SOURCE_TESTS.lock().unwrap_or_else(|e| e.into_inner());
@@ -903,11 +911,11 @@ fn a_window_does_not_keep_the_whole_source_on_the_device() {
return;
}
use lightcraft_pipeline::PixelWindow;
let src = scene(1, 6000, 4000);
let src = scene(1, BIG, BIG);
let info = SourceInfo { raw: true, ..Default::default() };
let stages = StageCache::default();
let s = DevelopSettings::default();
let req = RenderRequest { window: Some(PixelWindow { x: 2000, y: 1500, w: 640, h: 480 }), ..RenderRequest::fit(6000, 4000) };
let req = RenderRequest { window: Some(PixelWindow { x: 2000, y: 1500, w: 640, h: 480 }), ..RenderRequest::fit(BIG, BIG) };
let _ = lightcraft_gpu::render(&src, &info, &s, &req, Some(&stages)).expect("gpu render");
let held = lightcraft_gpu::stage_bytes(&stages);
assert!(held < 100 << 20, "the view's GPU stages hold {} MiB for a 640×480 window", held >> 20);
@@ -919,7 +927,7 @@ fn a_window_does_not_keep_the_whole_source_on_the_device() {
assert!(lightcraft_gpu::stage_bytes(&stages) < 100 << 20);
}
/// …but the 288 MB is uploaded once for every window of every view of the photo, not once per
/// …but the source is uploaded once for every window of every view of the photo, not once per
/// window: panning at 1:1, and the Before and After windows, share one device copy of it, which
/// goes when the app idles (`trim_pool(0)`).
#[test]
@@ -929,17 +937,17 @@ fn windows_of_a_big_photo_share_one_uploaded_source() {
return;
}
use lightcraft_pipeline::PixelWindow;
let src = scene(1, 6000, 4000);
let src = scene(1, BIG, BIG);
let info = SourceInfo { raw: true, ..Default::default() };
let s = DevelopSettings::default();
let (after, before) = (StageCache::default(), StageCache::default());
let at = |x: usize| RenderRequest { window: Some(PixelWindow { x, y: 1500, w: 640, h: 480 }), ..RenderRequest::fit(6000, 4000) };
let at = |x: usize| RenderRequest { window: Some(PixelWindow { x, y: 1500, w: 640, h: 480 }), ..RenderRequest::fit(BIG, BIG) };
let start = lightcraft_gpu::source_uploads();
for (cache, x) in [(&after, 1000), (&after, 1256), (&after, 1512), (&before, 1512), (&before, 1768)] {
lightcraft_gpu::render(&src, &info, &s, &at(x), Some(cache)).expect("gpu render");
}
assert_eq!(lightcraft_gpu::source_uploads() - start, 1, "one upload for five windows of two views");
assert!(lightcraft_gpu::shared_source_bytes() >= 280_000_000, "{}", lightcraft_gpu::shared_source_bytes());
assert!(lightcraft_gpu::shared_source_bytes() >= BIG * BIG * 12, "{}", lightcraft_gpu::shared_source_bytes());
assert!(lightcraft_gpu::stage_bytes(&after) < 100 << 20, "and it is not in the views' stages");
lightcraft_gpu::trim_pool(0);
assert_eq!(lightcraft_gpu::shared_source_bytes(), 0, "an idle app gives it back");
+21 -1
View File
@@ -168,7 +168,10 @@ impl Histogram {
/// Luma value (0..1) below which `q` of the pixels fall.
pub fn percentile(&self, q: f32) -> f32 {
let target = (q.clamp(0.0, 1.0) * self.total as f32) as u32;
if self.total == 0 {
return 0.0;
}
let target = ((q.clamp(0.0, 1.0) * self.total as f32) as u32).max(1);
let mut acc = 0;
for (i, &c) in self.luma.iter().enumerate() {
acc += c;
@@ -199,6 +202,23 @@ mod tests {
assert_eq!(h.above_sdr(), 0.0);
}
#[test]
fn percentile_zero_skips_empty_bins() {
let img = Rgba8::from_fn(4, 4, |_, _| [200, 200, 200, 255]);
let h = Histogram::of_srgb8(&img);
// every pixel is in one bin, so every percentile is that bin's luma, never black
assert!(h.percentile(1.0) > 0.0);
assert_eq!(h.percentile(0.0), h.percentile(1.0));
assert_eq!(h.percentile(0.01), h.percentile(1.0));
}
#[test]
fn percentile_of_empty_histogram_is_zero() {
let h = Histogram::of_hdr(&[], 4.0);
assert_eq!(h.percentile(0.0), 0.0);
assert_eq!(h.percentile(0.5), 0.0);
}
#[test]
fn hdr_bins_split_sdr_and_stops() {
let px = [[0.0, 0.0, 0.0], [1.0, 1.0, 1.0], [4.0, 4.0, 4.0], [16.0, 16.0, 16.0], [f32::NAN, -1.0, f32::INFINITY]];
+6 -1
View File
@@ -569,7 +569,12 @@ pub fn show_survey(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if app.session.selection.active == Some(*id) {
app.image_rect = Some(img);
}
if resp.clicked() {
// ⌘/Ctrl-click removes the photo from the survey (deselects it), as the × does
if resp.clicked() && ui.input(|i| i.modifiers.command) {
if photos.len() > 1 {
let _ = app.run("library.select", json!({"ids": [id.0], "mode": "toggle"}));
}
} else if resp.clicked() {
app.session.selection.active = Some(*id);
}
if resp.double_clicked() {
+57
View File
@@ -359,3 +359,60 @@ fn survey_rings_the_active_tile_in_the_theme_colour() {
.count();
assert_eq!(white, 0, "no white 2 pt frame");
}
/// Survey with three photos selected and the second active; returns the three ids.
fn survey_of_three(h: &mut Headless) -> [u64; 3] {
let vis: Vec<u64> = h.app.session.visible_cloned().iter().map(|p| p.0).collect();
let ids = [vis[0], vis[1], vis[2]];
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": ids, "active": ids[1]}}), T);
let r = h.request("engine.execute", json!({"command": "view.survey"}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
ids
}
/// Move the pointer to `pos` and press and release the primary button there, with `modifiers`.
fn click_with(h: &mut Headless, pos: egui::Pos2, modifiers: egui::Modifiers) {
h.app.synthetic.push(egui::Event::PointerMoved(pos));
h.settle(SETTLE);
h.app.synthetic.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers });
h.app.synthetic.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers });
h.settle(SETTLE);
}
/// Click the centre of photo `id`'s survey tile with `modifiers`.
fn click_tile(h: &mut Headless, id: u64, modifiers: egui::Modifiers) {
let name = format!("cull:{id}");
let pos = h.app.widgets.iter().find(|(w, _)| *w == name).map(|(_, r)| r.center()).expect("the tile is laid out");
click_with(h, pos, modifiers);
}
/// ⌘/Ctrl-click on a survey tile takes that photo out of the survey, as in Lightroom Classic,
/// whether or not it is the active one; a plain click only makes it active, and the last photo stays.
#[test]
fn survey_command_click_takes_the_photo_out() {
let mut h = demo();
let [a, b, c] = survey_of_three(&mut h);
let sel = |h: &Headless| h.app.session.selection.ids.iter().map(|p| p.0).collect::<Vec<u64>>();
let active = |h: &Headless| h.app.session.selection.active.map(|p| p.0);
click_tile(&mut h, a, egui::Modifiers::NONE);
assert_eq!((sel(&h), active(&h)), (vec![a, b, c], Some(a)), "a plain click keeps the photo and makes it active");
click_tile(&mut h, c, egui::Modifiers::COMMAND);
assert_eq!((sel(&h), active(&h)), (vec![a, b], Some(a)), "the ⌘/Ctrl-clicked photo (not the active one) left");
click_tile(&mut h, b, egui::Modifiers::COMMAND);
assert_eq!(sel(&h), vec![a]);
click_tile(&mut h, a, egui::Modifiers::COMMAND);
assert_eq!((sel(&h), active(&h)), (vec![a], Some(a)), "the last photo stays in the survey");
}
/// ⌘/Ctrl-click on the active survey photo while others remain: it leaves, and the last
/// remaining photo becomes active, so the keys still act on a photo on screen.
#[test]
fn survey_command_click_on_the_active_photo_activates_the_last_one() {
let mut h = demo();
let [a, b, c] = survey_of_three(&mut h);
click_tile(&mut h, a, egui::Modifiers::NONE);
click_tile(&mut h, a, egui::Modifiers::COMMAND);
let sel: Vec<u64> = h.app.session.selection.ids.iter().map(|p| p.0).collect();
assert_eq!((sel, h.app.session.selection.active.map(|p| p.0)), (vec![b, c], Some(c)));
}
+29
View File
@@ -49,6 +49,9 @@ Overrides (read once at launch):
- `LIGHTCRAFT_GPU=0`: GPU rendering off for the process (the window is unaffected).
- Both `LIGHTCRAFT_GPU=0` and `LIGHTCRAFT_GPU_BACKEND=off` also keep AI Denoise, the face models
and the AI mask model (SAM 3 on Metal) on the CPU.
- `LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1`: accept a software adapter (llvmpipe / lavapipe, WARP), which is
otherwise refused because the CPU pipeline is faster. For running the GPU tests on a machine
without a GPU (below); the app never sets it.
**DX12 shader compiler (issue #471).** Every instance (window and compute) compiles DX12 shaders with
FXC (`d3dcompiler_47.dll`, part of Windows; `lightcraft_gpu::backend::backend_options`). wgpu's
@@ -272,6 +275,32 @@ bit-identical, and one (the point colour range overlay) has max 2 LSB on 0.0002%
mean 0.0003 LSB. That is the same picture as on Metal, inside the bounds
(`cargo test -p lightcraft-gpu --release --test it equivalence:: -- --nocapture`).
**Running them without a GPU (issue #1050).** `Gpu::new` turns down software adapters, so on a
machine without a hardware GPU (a VM, a container) every GPU test skips.
`LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1` lets them through for one run (tests only: the app never sets it):
```sh
log="$(mktemp -d)/gpu-skips.jsonl" # a new private directory; an absolute path, as tests run in crates/gpu
LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1 LIGHTCRAFT_SKIP_LOG="$log" cargo test -p lightcraft-gpu --tests
grep '"need":"gpu"' "$log" | grep -v -e 'lost on purpose' -e 'memory never ran out' # no output: only the two below skipped
```
- **Linux:** Mesa's lavapipe (Vulkan; `mesa-vulkan-drivers` on Debian and Ubuntu). Its storage
bindings stop at 128 MiB (the WebGPU default), so the two big-source tests use a 117 MiB source:
above what a view keeps with its stages (96 MiB) and small enough to be uploaded there.
- **Windows:** WARP, the DX12 software rasterizer that ships with Windows (shaders through FXC, as
above).
- **macOS:** Metal is a hardware adapter, so the tests run without the variable.
A machine with a hardware GPU picks it over the software adapter, variable or not. Each test that
skips prints `skipped (gpu): …` and appends a line to the `LIGHTCRAFT_SKIP_LOG` file; with a software
adapter the only ones left should be the device-loss test (`no GPU device, or one that can't be lost on
purpose here`) and the device-memory test (`no GPU device, or its memory never ran out`: on lavapipe its
allocations never fail, seen 2026-10-31). Measured 2026-10-11 on lavapipe and WARP, and again 2026-10-30 on lavapipe (Mesa's
llvmpipe on LLVM 20, 143 compared renders), every compared render stayed within **max 1 LSB, mean ≤
0.0003 LSB**, as on the M4 Pro. The equivalence binary takes about 20 s on lavapipe and several
minutes on WARP.
Remaining differences come from f32 vs f64 coordinate math, fast-math transcendental functions on
Metal and summation order in the box filters — all far below one 8-bit step.
+1 -1
View File
@@ -134,7 +134,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| 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. Click zoom animates both panes together, including cached window tiles; input follows the displayed image and render requests use the final size (#1821) |
| 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); the active pane is framed in the active photo's two-tone selection ring (theme tokens, 3 pt), as in the grids; 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, the bottom bar and the Info panel's stars and colour labels act on the active photo (ringed as in the grid), each tile's caption shows its colour label, hover × removes |
| LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys, the bottom bar and the Info panel's stars and colour labels act on the active photo (ringed as in the grid), each tile's caption shows its colour label, hover × or ⌘/Ctrl-click removes a photo |
| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay`, `cmd:view.viewOptions`, `crates/ui-egui/src/panels/detail.rs` (`info_overlay`) | off / Info 1 (by default file name, capture date, size) / Info 2 (file name, exposure, camera + lens), each three lines chosen in View Options; ⌘I cycles (I in full screen; elsewhere I stays the Info panel); the star rating and flag show under the lines when the photo has them, and in the full-screen preview on macOS the overlay starts below the window controls (issue #2460) |
| LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen from the selected one, played with the library's playback settings (see LRC-SS-PLAYBACK: fades, random order, repeat, previews prepared ahead; `interval` / any setting overrides one run); Space pauses, ←/→ step (a cut), Esc ends; playback controls on pointer movement |
| LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) |