Merge track r1-ui into next

docs/whats-new.md: kept both tracks' entries, newest first, one 2026-10-18 heading.
This commit is contained in:
aiongg
2026-10-21 14:54:06 +00:00
35 changed files with 1562 additions and 185 deletions
+3 -1
View File
@@ -142,7 +142,9 @@ pub(crate) fn finish(format: Format, raw: Raw, meta: Meta, source_dims: (u32, u3
let (image, alpha) = match opts.max_size {
Some((mw, mh)) if (image.width as u32 > mw || image.height as u32 > mh) && mw > 0 && mh > 0 => {
let factor = (image.width as f64 / mw as f64).max(image.height as f64 / mh as f64);
let f = if factor >= 2.0 { Filter::Box } else { Filter::Mitchell };
// (Lanczos at 2:1 and more as well: a box average there halves the contrast of
// detail near the output's resolution limit and aliases what is finer)
let f = if factor >= 2.0 { Filter::Lanczos3 } else { Filter::Mitchell };
let img = fit(&image, mw as usize, mh as usize, f);
let alpha = alpha.map(|a| fit(&a, mw as usize, mh as usize, f));
(img, alpha)
+25 -8
View File
@@ -77,13 +77,22 @@ fn aspect_tuple(c: &str, x: f64, y: f64) -> Result<(u32, u32)> {
Ok((a as u32, b as u32))
}
fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>) {
/// How a new aspect ratio is turned (issue #2253).
#[derive(Clone, Copy, PartialEq)]
enum Turn {
/// As the crop is now: a named ratio ("2x3", "original"…) lands landscape on a landscape crop
/// and portrait on a portrait one (after X), as in Lightroom. A square crop follows the image.
LikeCrop,
/// As given: a custom `[w, h]` taller than wide is a portrait crop.
AsGiven,
}
fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>, turn: Turn) {
let r = d.crop.geometry.rect_px(w, h);
let landscape = if (r.width() - r.height()).abs() > 1e-6 * r.width().max(r.height()) { r.width() > r.height() } else { w >= h };
let aspect = aspect.map(|(aw, ah)| if turn == Turn::LikeCrop && (aw >= ah) != landscape { (ah, aw) } else { (aw, ah) });
d.crop.aspect = aspect;
let a = aspect.map(|(aw, ah)| {
// orientation of the crop follows the image (landscape image → landscape crop)
let (aw, ah) = (aw as f64, ah as f64);
if (w >= h) == (aw >= ah) { aw / ah } else { ah / aw }
});
let a = aspect.map(|(aw, ah)| aw as f64 / ah as f64);
let angle = d.crop.geometry.angle;
d.crop.geometry = crop_fit_angle(w, h, angle, a);
}
@@ -628,11 +637,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Crop Aspect",
[],
None,
"{aspect: \"original\"|\"free\"|\"current\" (lock the crop's present shape)|\"toggle\" (lock ↔ free)|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h]}",
"{aspect: \"original\"|\"free\"|\"current\" (lock the crop's present shape)|\"toggle\" (lock ↔ free)|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h], orientation?: \"crop\"|\"given\"} — a named ratio takes the crop's present orientation (landscape, or portrait after X); [w,h] is used as given (taller than wide → portrait); `orientation` overrides either",
has_active,
|s, p| {
let id = active(s, "crop.aspect")?;
let (w, h) = crop_dims(s, id);
// checked first, whatever the aspect: a bad value is an error on every path
let turn = match p.get("orientation") {
Some(Value::String(o)) if o == "crop" => Turn::LikeCrop,
Some(Value::String(o)) if o == "given" => Turn::AsGiven,
Some(_) => return Err(bad("crop.aspect", "orientation is \"crop\" or \"given\"")),
None if matches!(p.get("aspect"), Some(Value::Array(_))) => Turn::AsGiven,
None => Turn::LikeCrop,
};
let locked = s.develop_of(id).is_some_and(|d| d.crop.aspect.is_some());
let lock_current = match p.get("aspect").and_then(Value::as_str) {
Some("current") => true,
@@ -667,7 +684,7 @@ pub fn specs() -> Vec<CommandSpec> {
if aspect.is_none() {
d.crop.aspect = None;
} else {
set_aspect(d, w, h, aspect);
set_aspect(d, w, h, aspect, turn);
}
Ok(())
})
+31
View File
@@ -152,6 +152,37 @@ mod tests {
}
}
/// ⌥-drag views on a profiled display (issue #937): the histogram stays the photo's sRGB one
/// (not the view's, nor the display-primary pixels'), and the clipping view is tested and
/// drawn in sRGB, so it stays black and white.
#[test]
fn diagnostic_views_keep_the_photos_histogram() {
use lightcraft_pipeline::Overlay;
let mut session = crate::Session::with_demo();
let id = session.visible_cloned()[0];
let d = std::sync::Arc::new(p3());
let mut run = |o, d: Option<std::sync::Arc<Display>>| {
session.loupe_job(id, 320, 240, true).unwrap().with_display(d).with_overlay(o).run().rendered.unwrap()
};
let plain = run(Overlay::None, Some(d.clone()));
let clip = run(Overlay::ShadowClip, Some(d.clone()));
let (a, b) = (plain.histogram.mean_luma(), clip.histogram.mean_luma());
assert!((a - b).abs() < 0.02, "the photo's histogram: {a} vs {b}");
// each channel's mean level: the photo's, as a render without a display profile counts it
let means = |h: &Histogram| {
[&h.r, &h.g, &h.b].map(|c| c.iter().enumerate().map(|(i, n)| i as f64 * f64::from(*n)).sum::<f64>() / f64::from(h.total.max(1)))
};
let srgb = means(&run(Overlay::None, None).histogram);
for o in [Overlay::ShadowClip, Overlay::HighlightClip, Overlay::Grey, Overlay::SharpenMask] {
let v = means(&run(o, Some(d.clone())).histogram);
assert!(srgb.iter().zip(v).all(|(p, v)| (p - v).abs() < 0.05), "{o:?}: the photo's sRGB histogram {srgb:?}, got {v:?}");
}
let px = &clip.image.data;
// (the profile may round white to 254)
let binary = px.iter().filter(|p| p[..3].iter().all(|v| *v <= 2 || *v >= 253)).count();
assert!(binary as f64 > 0.99 * px.len() as f64, "{binary} of {}", px.len());
}
#[test]
fn load_reports_and_rejects() {
let dir = std::env::temp_dir().join(format!("lightcraft-display-{}", std::process::id()));
+1
View File
@@ -1566,6 +1566,7 @@ impl Session {
display: None,
slide: None,
preview_cut: None,
display_fit: None,
};
let embedded = match (&self.media.preview_loader, c.kind) {
(Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)),
+61 -11
View File
@@ -186,6 +186,10 @@ pub struct Interaction {
pub label: String,
pub photo: PhotoId,
pub original: Arc<DevelopSettings>,
/// Whether an edit during the interaction changed the crop itself. If none did, the crop the
/// photo shows was fitted by Constrain Crop, and the final commit starts again from the original
/// crop, so Auto Sync carries the edit and not the active photo's fitted crop.
pub crop_edited: bool,
}
/// The most photos an edit may touch for the view to follow it photo by photo; a larger edit is worked out whole.
@@ -881,22 +885,58 @@ impl Session {
/// undo step; otherwise it is committed with a history entry.
pub fn set_develop(&mut self, id: PhotoId, settings: DevelopSettings, label: &str) -> Result<()> {
let now = (self.clock)();
let settings = Arc::new(settings);
if let Some(i) = &self.interaction
// Auto Sync carries the edit as asked; each photo then fits its own crop (review of #1990)
let requested = settings;
let settings = Arc::new(self.fit_constrained_crop(id, requested.clone()));
let crop_edited = self.develop_of(id).is_some_and(|d| d.crop.geometry != requested.crop.geometry);
if let Some(i) = &mut self.interaction
&& i.photo == id
{
i.crop_edited |= crop_edited;
return self.apply_silent(Op::SetDevelop { id, settings, label: label.into(), edited: Some(now) });
}
let mut ops = vec![self.develop_op(id, (*settings).clone(), label).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?];
ops.extend(self.auto_sync_ops(id, &settings, label));
ops.extend(self.auto_sync_ops(id, &requested, label));
let op = if ops.len() == 1 { ops.remove(0) } else { Op::Batch { ops } };
self.commit(label, op)
}
/// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`) over to the
/// other selected photos: only the settings that changed; never spot removal or red eye (they
/// belong to one photo's pixels), nor the rendering process (Sync doesn't carry it either), nor
/// history / snapshot restores.
/// "Constrain Crop" keeps the stored crop inside the warped photo, as Lightroom does (issue
/// #1927): turning it on, or an edit that changes the warp (Geometry, Upright, lens corrections,
/// their sections' eyes) while it is on, shrinks the crop about its centre until no blank corner
/// is left, so the Crop tool shows what the render keeps. An edit of the crop itself is left as
/// drawn (the render still constrains it).
fn fit_constrained_crop(&self, id: PhotoId, mut d: DevelopSettings) -> DevelopSettings {
if !d.geometry.constrain_crop {
return d;
}
let Some(photo) = self.catalog.photo(id) else { return d };
let before = &photo.develop;
let turned_on = !before.geometry.constrain_crop;
let warp_changed = before.geometry != d.geometry
|| before.optics != d.optics
|| before.orientation != d.orientation
|| ["geometry", "optics"].iter().any(|s| before.section_enabled(s) != d.section_enabled(s));
let crop_edited = before.crop.geometry != d.crop.geometry;
if !(turned_on || (warp_changed && !crop_edited)) {
return d;
}
let frame = lightcraft_pipeline::geometry::Frame::with_lens(
photo.width.max(1) as usize,
photo.height.max(1) as usize,
&d,
true,
photo.embedded_lens.as_ref(),
);
d.crop.geometry.rect = frame.crop.rect;
d
}
/// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`, as asked,
/// before its crop is fitted) over to the other selected photos: only the settings that changed;
/// never spot removal or red eye (they belong to one photo's pixels), nor the rendering process
/// (Sync doesn't carry it either), nor history / snapshot restores. Constrain Crop then fits each
/// photo's own crop: the active photo's fitted crop is never copied over another's.
fn auto_sync_ops(&self, id: PhotoId, new: &DevelopSettings, label: &str) -> Vec<Op> {
if !self.auto_sync
|| self.active() != Some(id)
@@ -922,7 +962,7 @@ impl Session {
.filter(|x| **x != id)
.filter_map(|x| self.develop_of(*x).map(|d| (*x, d)))
.filter_map(|(x, d)| {
let synced = lightcraft_develop::apply_partial(&d, &delta, 1.0);
let synced = self.fit_constrained_crop(x, lightcraft_develop::apply_partial(&d, &delta, 1.0));
(synced != *d).then(|| self.develop_op(x, synced, label)).flatten()
})
.collect()
@@ -944,7 +984,7 @@ impl Session {
}
let id = self.active().ok_or_else(|| EngineError::Other("no active photo".into()))?;
let original = self.develop_of(id).unwrap_or_default();
self.interaction = Some(Interaction { label: label.into(), photo: id, original });
self.interaction = Some(Interaction { label: label.into(), photo: id, original, crop_edited: false });
Ok(())
}
@@ -955,9 +995,15 @@ impl Session {
if *cur == *i.original {
return Ok(());
}
// Restore the original silently, then commit the final value as one step.
// Restore the original silently, then commit the final value as one step. A crop nobody
// edited goes back to the original's: Constrain Crop fits it again for the final warp, and
// Auto Sync carries the edit, not the active photo's fitted crop (review of #1990).
self.apply_silent(Op::SetDevelop { id: i.photo, settings: i.original.clone(), label: i.label.clone(), edited: None })?;
self.set_develop(i.photo, (*cur).clone(), &i.label)
let mut last = (*cur).clone();
if !i.crop_edited {
last.crop.geometry = i.original.crop.geometry;
}
self.set_develop(i.photo, last, &i.label)
}
pub fn cancel_interaction(&mut self) -> Result<()> {
@@ -1249,8 +1295,12 @@ mod tests_albums_local;
#[cfg(test)]
mod tests_color;
#[cfg(test)]
mod tests_constrain_crop;
#[cfg(test)]
mod tests_crop_angle;
#[cfg(test)]
mod tests_crop_aspect;
#[cfg(test)]
mod tests_denoise;
#[cfg(test)]
mod tests_export;
+20 -5
View File
@@ -84,6 +84,9 @@ pub struct Library {
/// Why the last append of queued ops failed; `None` once a save succeeds. While set, changes
/// exist only in memory ([`Session::unsaved`]).
pub unsaved_error: Option<String>,
/// Why the last write of presets.json failed; `None` once it is written. While set, the preset
/// changes exist only in memory and count as unsaved ([`Session::unsaved`], issue #2266).
presets_error: Option<String>,
/// When the frame loop may retry a failed append ([`Session::persist_if_dirty`] backs off).
retry_at: Option<web_time::Instant>,
/// What forgetting untouched Local records did when the library opened.
@@ -577,6 +580,7 @@ impl Session {
report,
last_error: None,
unsaved_error: None,
presets_error: None,
retry_at: None,
forgot_local: None,
presets_saved,
@@ -651,10 +655,17 @@ impl Session {
let Some(lib) = self.library.as_mut() else { return Ok(()) };
if state.hash == lib.presets_saved.hash {
lib.presets_saved = state;
lib.presets_error = None;
} else if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) {
// the state stays stale: the next save tries again
// the state stays stale: retried by the next command and the frame loop (backing off);
// meanwhile the presets count as unsaved, so the UI warns and a backup refuses
log::error!("library: presets: {e}");
lib.presets_error = Some(format!("presets: {e}"));
lib.retry_at = Some(web_time::Instant::now() + RETRY_BACKOFF);
} else {
if lib.presets_error.take().is_some() {
log::info!("library: presets written to disk");
}
lib.presets_saved = state;
}
Ok(())
@@ -664,7 +675,8 @@ impl Session {
/// changed outside [`Session::execute`]).
pub fn persist_if_dirty(&mut self) {
let backing_off = self.library.as_ref().and_then(|l| l.retry_at).is_some_and(|t| web_time::Instant::now() < t);
if self.library.is_some() && !self.pending_log.is_empty() && !backing_off {
let presets_unsaved = self.library.as_ref().is_some_and(|l| l.presets_error.is_some());
if self.library.is_some() && (!self.pending_log.is_empty() || presets_unsaved) && !backing_off {
let _ = self.persist();
} else if let Some(lib) = self.library.as_mut() {
poll_compaction(lib);
@@ -672,11 +684,14 @@ impl Session {
}
/// Changes applied in memory but not yet written to disk because saving failed: the number of
/// queued ops and why (`None` when everything is saved, or no library is open). Retried by
/// every command and by [`Session::persist_if_dirty`].
/// queued ops (plus one for presets.json) and why (`None` when everything is saved, or no library
/// is open). Retried by every command and by [`Session::persist_if_dirty`].
pub fn unsaved(&self) -> Option<(usize, &str)> {
let lib = self.library.as_ref()?;
lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty()).map(|e| (self.pending_log.len(), e))
let ops = lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty());
let presets = lib.presets_error.as_deref();
let n = ops.map_or(0, |_| self.pending_log.len()) + usize::from(presets.is_some());
ops.or(presets).map(|why| (n, why))
}
/// Flush everything and write a snapshot (on quit). Ends an open interaction first.
+77 -2
View File
@@ -277,7 +277,9 @@ impl SourceRef {
if from.image.width.max(from.image.height) <= *max_edge {
return Ok((**from).clone());
}
let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Box);
// Lanczos, not a box average: a box at 2-3:1 halves the contrast of detail near
// the output's resolution limit and aliases what is finer
let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Lanczos3);
return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() });
}
SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?,
@@ -761,6 +763,24 @@ pub struct RenderJob {
/// A face tile that can be cut from the raw's embedded preview, tried before `source`
/// ([`crate::Session::face_job`]; `None` for every other job).
pub preview_cut: Option<crate::face_tiles::PreviewCut>,
/// Shrink a full-quality result to fit this size (physical pixels) with a Lanczos filter once
/// it is rendered. The loupe renders a little larger than it is drawn (its edge is rounded up
/// to a 64 px step) and the window would otherwise shrink the texture with 2-tap bilinear sampling, which blurs fine detail at
/// small ratios and aliases it at large ones. The caches keep the full render.
pub display_fit: Option<(usize, usize)>,
}
/// `img` shrunk to fit `max_w × max_h` with a Lanczos filter (see [`RenderJob::display_fit`]).
pub fn fit_for_display(img: &Rgba8, max_w: usize, max_h: usize) -> Rgba8 {
use lightcraft_raster::resample::{Filter, fit};
let f = lightcraft_raster::Image {
width: img.width,
height: img.height,
data: img.data.iter().map(|p| [f32::from(p[0]), f32::from(p[1]), f32::from(p[2]), f32::from(p[3])]).collect(),
};
let small = fit(&f, max_w.max(1), max_h.max(1), Filter::Lanczos3);
let c = |v: f32| v.round().clamp(0.0, 255.0) as u8;
Rgba8 { width: small.width, height: small.height, data: small.data.iter().map(|p| [c(p[0]), c(p[1]), c(p[2]), c(p[3])]).collect() }
}
pub struct RenderResult {
@@ -784,6 +804,14 @@ pub struct RenderResult {
impl RenderJob {
/// Render at draft quality (interactive drags). Gets its own result key.
/// Shrink the full-quality result to fit `w × h` pixels with a Lanczos filter (see
/// [`Self::display_fit`]). Gets its own result key, so a change of drawn size renders again.
pub fn with_display_fit(mut self, w: usize, h: usize) -> Self {
self.display_fit = Some((w, h));
self.key ^= ((w as u64) << 32 | h as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15);
self
}
pub fn draft(mut self) -> Self {
if self.request.quality != Quality::Draft {
self.request.quality = Quality::Draft;
@@ -968,11 +996,24 @@ impl RenderJob {
// Thumbnails (many small jobs side by side) stay on the CPU; views and exports use
// the GPU when there is one.
let gpu = self.cache.is_none();
let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu);
// a clipping or grey view: rendered in sRGB (the clipping test and the histogram
// read sRGB values), then converted for the display below
let srgb_view = self.request.overlay.renders_in_srgb();
let request = if srgb_view { RenderRequest { display: None, ..self.request } } else { self.request };
let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &request, self.stages.as_deref(), gpu);
// for a display profile: the image goes to the display's values, the histogram
// and the cached view preview stay sRGB
let mut srgb = None;
let display = match (&self.display, self.request.display) {
// a diagnostic view: the histogram stays the photo's (taken before the overlay
// was drawn), and nothing goes to the view cache
(Some(d), Some(_)) if self.request.overlay != lightcraft_pipeline::Overlay::None => {
let converted = if srgb_view { d.profile.from_srgb(&mut rendered.image) } else { d.profile.correct(&mut rendered.image) };
if let Err(e) = converted {
log::warn!("display profile: {e}");
}
Some(d.id())
}
(Some(d), Some(_)) => {
let keep = self.view_cache.is_some() && self.request.quality == Quality::Full;
let (s, h) = crate::display::finish_render(&mut rendered.image, d, keep);
@@ -994,6 +1035,15 @@ impl RenderJob {
{
cache.put_deferred_at(self.cache_generation, *key, Arc::new(srgb.unwrap_or_else(|| rendered.image.clone())));
}
// the display fit, after the caches have the full render
if let Some((fw, fh)) = self.display_fit
&& self.request.quality == Quality::Full
{
let (w, h) = (rendered.image.width, rendered.image.height);
if w > fw.max(1) || h > fh.max(1) {
rendered.image = fit_for_display(&rendered.image, fw, fh);
}
}
RenderResult {
request_id: self.request_id,
source_key: self.source_key,
@@ -1266,6 +1316,7 @@ impl crate::Session {
display: None,
slide: None,
preview_cut: None,
display_fit: None,
})
}
@@ -1319,6 +1370,7 @@ impl crate::Session {
display: None,
slide: None,
preview_cut: None,
display_fit: None,
})
}
@@ -1373,6 +1425,7 @@ impl crate::Session {
display: None,
slide: None,
preview_cut,
display_fit: None,
})
}
@@ -1798,6 +1851,28 @@ mod thumbnail_hash_tests {
mod tests {
use super::*;
/// A loupe job with a display fit returns its image shrunk to the drawn size, aspect kept;
/// a draft, and a render no larger than the drawn size, are left as rendered.
#[test]
fn a_display_fit_shrinks_the_loupe_render_to_the_drawn_size() {
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let plain = s.loupe_job(id, 1024, 1024, true).unwrap().run().rendered.unwrap().image;
let long = plain.width.max(plain.height);
assert!(long >= 600, "the demo renders at least 600 px at 1024: {long}");
let fitted = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400).run().rendered.unwrap().image;
assert_eq!(fitted.width.max(fitted.height), 400, "{}x{}", fitted.width, fitted.height);
let aspect = |i: &Rgba8| i.width as f32 / i.height as f32;
assert!((aspect(&fitted) - aspect(&plain)).abs() < 0.02, "aspect kept");
let draft = s.loupe_job(id, 1024, 1024, true).unwrap().draft().with_display_fit(400, 400).run().rendered.unwrap().image;
assert_eq!(draft.width.max(draft.height), long, "a draft is not shrunk");
let same = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(plain.width, plain.height).run().rendered.unwrap().image;
assert_eq!((same.width, same.height), (plain.width, plain.height), "no larger than drawn: left as rendered");
let a = s.loupe_job(id, 1024, 1024, true).unwrap();
let b = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400);
assert_ne!(a.key, b.key, "the fit is part of the result key");
}
#[test]
fn renders_know_the_original_size_whichever_level_they_come_from() {
// issue #960: noise reduction works in the original's pixels, so a grid cell drawn from the
+147
View File
@@ -0,0 +1,147 @@
//! Constrain Crop changes the stored crop, as in Lightroom (issue #1927): with it on, the crop the
//! Crop tool shows (the stored one) never takes in the blank corners a Geometry warp leaves, so
//! ticking it, or moving a Geometry slider while it is ticked, visibly tightens the crop.
use lightcraft_geom::{CropGeometry, Rect};
use lightcraft_pipeline::geometry::Frame;
use serde_json::json;
use crate::Session;
fn crop(s: &Session) -> CropGeometry {
s.develop_of(s.active().unwrap()).unwrap().crop.geometry
}
/// Does the stored crop already fit the warped photo (the render's constraint would leave it alone)?
fn fits(s: &Session) -> bool {
let p = s.catalog.photo(s.active().unwrap()).unwrap();
let mut d = (*p.develop).clone();
d.geometry.constrain_crop = true;
let f = Frame::with_lens(p.width as usize, p.height as usize, &d, true, p.embedded_lens.as_ref());
let (a, b) = (f.crop.rect, p.develop.crop.geometry.rect);
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-9)
}
fn smaller(a: Rect, b: Rect) -> bool {
a.width() < b.width() - 1e-3 && a.height() < b.height() - 1e-3
}
fn constrain(s: &mut Session, on: bool) {
s.execute("develop.merge", &json!({"settings": {"geometry": {"constrain_crop": on}}, "label": "Constrain Crop"})).unwrap();
}
fn vertical(s: &mut Session, v: f64) {
s.execute("develop.set", &json!({"control": "geometry.vertical", "value": v})).unwrap();
}
#[test]
fn a_geometry_slider_tightens_the_crop_while_constrained() {
let mut s = Session::with_demo();
let full = crop(&s).rect;
constrain(&mut s, true);
assert_eq!(crop(&s).rect, full, "no warp yet: nothing to cut off");
vertical(&mut s, -60.0);
assert!(smaller(crop(&s).rect, full), "the stored crop shrank: {:?}", crop(&s).rect);
assert!(fits(&s), "and holds no blank corner");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(crop(&s).rect, full, "undo puts the crop back with the slider");
}
#[test]
fn ticking_constrain_after_the_warp_tightens_the_crop() {
let mut s = Session::with_demo();
let full = crop(&s).rect;
vertical(&mut s, -60.0);
assert_eq!(crop(&s).rect, full, "unconstrained: the crop keeps the blank corners");
assert!(!fits(&s));
constrain(&mut s, true);
assert!(smaller(crop(&s).rect, full) && fits(&s), "{:?}", crop(&s).rect);
// unticking keeps the tighter crop, as in Lightroom
let tight = crop(&s).rect;
constrain(&mut s, false);
assert_eq!(crop(&s).rect, tight);
}
#[test]
fn a_crop_drawn_by_hand_is_kept_as_drawn() {
let mut s = Session::with_demo();
constrain(&mut s, true);
vertical(&mut s, -60.0);
s.execute("crop.set", &json!({"rect": [0.0, 0.0, 1.0, 1.0]})).unwrap();
assert_eq!(crop(&s).rect, Rect::new(0.0, 0.0, 1.0, 1.0), "the crop edit itself is not refitted");
}
/// Review of #1990: with Auto Sync, a Geometry edit on the active photo fits each selected photo's
/// own crop; the active photo's fitted crop is never copied over another photo's (smaller) crop.
#[test]
fn auto_sync_fits_each_photos_own_crop() {
let mut s = Session::with_demo();
let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
let (a, b) = (ids[0], ids[1]);
// B: a small crop off-centre; both constrained
s.selection = crate::Selection { ids: vec![b], active: Some(b) };
s.execute("crop.set", &json!({"rect": [0.05, 0.1, 0.45, 0.5]})).unwrap();
constrain(&mut s, true);
s.selection = crate::Selection { ids: vec![a], active: Some(a) };
constrain(&mut s, true);
let b_before = s.develop_of(b).unwrap().crop.geometry.rect;
// A active, both selected, Auto Sync on: a Geometry edit on A
s.selection = crate::Selection { ids: vec![a, b], active: Some(a) };
s.execute("develop.autoSync", &json!({"on": true})).unwrap();
vertical(&mut s, -60.0);
let (da, db) = (s.develop_of(a).unwrap(), s.develop_of(b).unwrap());
assert_eq!(db.geometry.vertical, -60.0, "the edit reached B");
let b_after = db.crop.geometry.rect;
assert_ne!(b_after, da.crop.geometry.rect, "B keeps a crop of its own");
let inside = |r: Rect, o: Rect| r.x0 >= o.x0 - 1e-9 && r.y0 >= o.y0 - 1e-9 && r.x1 <= o.x1 + 1e-9 && r.y1 <= o.y1 + 1e-9;
assert!(inside(b_after, b_before), "B's crop only shrinks within itself: {b_before:?} -> {b_after:?}");
s.selection = crate::Selection { ids: vec![b], active: Some(b) };
assert!(fits(&s), "and fits B's warp");
}
/// Review of #1990 (CodeRabbit): the same with a slider drag (an interaction), the path the
/// window uses: the drag fits A's crop live, and its end must not carry that crop over to B.
#[test]
fn auto_sync_after_a_slider_drag_fits_each_photos_own_crop() {
let mut s = Session::with_demo();
let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
let (a, b) = (ids[0], ids[1]);
s.selection = crate::Selection { ids: vec![b], active: Some(b) };
s.execute("crop.set", &json!({"rect": [0.05, 0.1, 0.45, 0.5]})).unwrap();
constrain(&mut s, true);
s.selection = crate::Selection { ids: vec![a], active: Some(a) };
constrain(&mut s, true);
let b_before = s.develop_of(b).unwrap().crop.geometry.rect;
s.selection = crate::Selection { ids: vec![a, b], active: Some(a) };
s.execute("develop.autoSync", &json!({"on": true})).unwrap();
s.execute("develop.beginInteraction", &json!({"label": "Vertical"})).unwrap();
for v in [-20.0, -40.0, -60.0] {
vertical(&mut s, v);
}
s.execute("develop.endInteraction", &json!({})).unwrap();
let (da, db) = (s.develop_of(a).unwrap(), s.develop_of(b).unwrap());
assert_eq!((da.geometry.vertical, db.geometry.vertical), (-60.0, -60.0));
let b_after = db.crop.geometry.rect;
assert_ne!(b_after, da.crop.geometry.rect, "B keeps a crop of its own");
let inside = |r: Rect, o: Rect| r.x0 >= o.x0 - 1e-9 && r.y0 >= o.y0 - 1e-9 && r.x1 <= o.x1 + 1e-9 && r.y1 <= o.y1 + 1e-9;
assert!(inside(b_after, b_before), "{b_before:?} -> {b_after:?}");
for photo in [a, b] {
s.selection = crate::Selection { ids: vec![photo], active: Some(photo) };
assert!(fits(&s), "each crop fits its photo's warp");
}
}
/// Review of #1990 (CodeRabbit): only the Geometry and Optics eyes change the warp; turning
/// another section's eye off leaves a crop drawn by hand as it is.
#[test]
fn an_unrelated_section_eye_leaves_a_hand_drawn_crop() {
let mut s = Session::with_demo();
constrain(&mut s, true);
vertical(&mut s, -60.0);
s.execute("crop.set", &json!({"rect": [0.0, 0.0, 1.0, 1.0]})).unwrap();
s.execute("develop.sectionEnabled", &json!({"section": "light", "enabled": false})).unwrap();
assert_eq!(crop(&s).rect, Rect::new(0.0, 0.0, 1.0, 1.0), "the Light eye is no warp change");
s.execute("develop.sectionEnabled", &json!({"section": "geometry", "enabled": false})).unwrap();
s.execute("develop.sectionEnabled", &json!({"section": "geometry", "enabled": true})).unwrap();
assert!(fits(&s), "the Geometry eye is one: the crop fits again");
}
+86
View File
@@ -0,0 +1,86 @@
//! Issue #2253: a crop ratio keeps the orientation asked for. A named ratio ("2x3", "4x5",
//! "original") takes the crop's present orientation, landscape or portrait after X, as in
//! Lightroom; a custom `[w, h]` is used as given, so 2 × 3 is a portrait crop.
use serde_json::{Value, json};
use crate::Session;
/// The demo photo, landscape, with its crop's width and height in pixels.
fn crop_px(s: &Session) -> (f64, f64) {
let p = s.catalog.photo(s.active().unwrap()).unwrap();
let (w, h) = (p.width as f64, p.height as f64);
let (w, h) = if p.develop.orientation.swaps_axes() { (h, w) } else { (w, h) };
let r = p.develop.crop.geometry.rect_px(w, h);
(r.width(), r.height())
}
fn landscape_photo() -> Session {
let mut s = Session::with_demo();
let id = s.catalog.photos().find(|p| p.width > p.height && !p.develop.orientation.swaps_axes()).map(|p| p.id).unwrap();
s.selection = crate::Selection { ids: vec![id], active: Some(id) };
s
}
fn aspect(s: &mut Session, p: Value) {
s.execute("crop.aspect", &p).unwrap();
}
fn ratio(s: &Session) -> f64 {
let (w, h) = crop_px(s);
w / h
}
#[test]
fn a_named_ratio_follows_the_crop_and_x_keeps_it_portrait() {
let mut s = landscape_photo();
aspect(&mut s, json!({"aspect": "2x3"}));
assert!((ratio(&s) - 1.5).abs() < 0.01, "landscape crop: {}", ratio(&s));
s.execute("crop.rotateAspect", &json!({})).unwrap();
assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "X turns it portrait: {}", ratio(&s));
// another named ratio after X stays portrait (it used to turn back to the image's landscape)
aspect(&mut s, json!({"aspect": "4x5"}));
assert!((ratio(&s) - 0.8).abs() < 0.01, "still portrait: {}", ratio(&s));
aspect(&mut s, json!({"aspect": "16x9"}));
assert!((ratio(&s) - 9.0 / 16.0).abs() < 0.01, "{}", ratio(&s));
}
#[test]
fn a_custom_ratio_is_used_as_given() {
let mut s = landscape_photo();
aspect(&mut s, json!({"aspect": [2, 3]}));
assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "custom 2 × 3 is portrait: {}", ratio(&s));
aspect(&mut s, json!({"aspect": [3, 2]}));
assert!((ratio(&s) - 1.5).abs() < 0.01, "{}", ratio(&s));
}
/// The window reapplies the last ratio to an untouched photo with `orientation: "crop"`: on a
/// landscape photo it lands landscape, whatever photo it was chosen on.
#[test]
fn orientation_overrides_and_is_checked() {
let mut s = landscape_photo();
aspect(&mut s, json!({"aspect": [2, 3], "orientation": "crop"}));
assert!((ratio(&s) - 1.5).abs() < 0.01, "{}", ratio(&s));
aspect(&mut s, json!({"aspect": "2x3", "orientation": "given"}));
assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "{}", ratio(&s));
let e = s.execute("crop.aspect", &json!({"aspect": "2x3", "orientation": "sideways"})).unwrap_err();
assert!(e.to_string().contains("orientation"), "{e}");
}
/// A bad `orientation` is refused on every path, the lock-as-is ones ("current", an unlocked
/// "toggle") included, and whether it is a wrong word or not a string at all; nothing changes.
#[test]
fn a_bad_orientation_is_refused_before_anything_is_locked() {
let mut s = landscape_photo();
let before = s.develop_of(s.selection.active.unwrap()).unwrap().crop.clone();
for aspect in [json!("current"), json!("toggle"), json!("2x3"), json!([2, 3]), json!("free")] {
for orientation in [json!("sideways"), json!(1), json!(true), json!(null)] {
let e = s.execute("crop.aspect", &json!({"aspect": aspect, "orientation": orientation})).unwrap_err();
assert!(e.to_string().contains("orientation"), "{aspect} {orientation}: {e}");
assert_eq!(s.develop_of(s.selection.active.unwrap()).unwrap().crop, before, "{aspect} {orientation}: untouched");
}
}
// the valid words still lock as is
aspect(&mut s, json!({"aspect": "current", "orientation": "crop"}));
assert!(s.develop_of(s.selection.active.unwrap()).unwrap().crop.aspect.is_some());
}
+25
View File
@@ -191,3 +191,28 @@ fn close_fails_when_nothing_can_be_saved() {
s.close_library().unwrap();
assert_eq!(s.unsaved(), None);
}
/// Issue #2266: a presets.json that can't be written isn't only logged: the preset counts as
/// unsaved (the top bar warns, the web backup refuses) until a later save writes it.
#[test]
fn a_preset_that_cannot_be_written_counts_as_unsaved_until_it_is() {
let files = Flaky::default();
let mut s = Session::new();
let stores = LibraryStores { dir: "flaky".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false };
s.open_library_in(stores, true).unwrap();
assert!(s.unsaved().is_none());
files.set_failing(true);
s.execute("preset.create", &json!({"name": "Mine"})).unwrap();
let (n, why) = s.unsaved().expect("the preset is unsaved");
assert_eq!(n, 1);
assert!(why.contains("presets") && why.contains("disk full"), "{why}");
let stored = |f: &Flaky| f.files.files.lock().unwrap().get("presets.json").cloned().unwrap_or_default();
assert!(!String::from_utf8_lossy(&stored(&files)).contains("Mine"));
// still failing: still unsaved
s.persist().unwrap();
assert!(s.unsaved().is_some());
files.set_failing(false);
s.persist().unwrap();
assert!(s.unsaved().is_none(), "written once the disk works again");
assert!(String::from_utf8_lossy(&stored(&files)).contains("Mine"));
}
+7 -3
View File
@@ -116,7 +116,8 @@ pub fn last_fallback() -> Option<String> {
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Fallback {
/// The GPU was not tried: it is unavailable, disabled or closing ([`unavailable_reason`]), or
/// the request is one only the CPU renders (high bit depth, soft proof, HDR range overlay).
/// the request is one only the CPU renders (high bit depth, soft proof, HDR range or sharpening
/// mask overlay).
NotTried,
/// The render was tried on the GPU and failed, for this reason (the same text
/// [`last_fallback`] reports). The CPU renders it instead.
@@ -451,10 +452,13 @@ pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &R
/// Whether the GPU renders requests of this kind at all (a given render can still fall back to
/// the CPU): the kernel writes 8-bit output, so high-bit-depth exports and soft proofs render on
/// the CPU, and so does Visualize HDR, which needs the float HDR render. [`try_render`] follows it,
/// the CPU, and so does Visualize HDR, which needs the float HDR render, and the sharpening mask,
/// which needs the detail planes. [`try_render`] follows it,
/// and the loupe sizes its slider-drag drafts by it (issue #330), so the two can't drift apart.
pub fn handles(depth: lightcraft_pipeline::OutputDepth, proofed: bool, overlay: lightcraft_pipeline::Overlay) -> bool {
depth == lightcraft_pipeline::OutputDepth::U8 && !proofed && overlay != lightcraft_pipeline::Overlay::HdrRange
depth == lightcraft_pipeline::OutputDepth::U8
&& !proofed
&& !matches!(overlay, lightcraft_pipeline::Overlay::HdrRange | lightcraft_pipeline::Overlay::SharpenMask)
}
/// [`render`], reporting why this render made no GPU image: [`Fallback::NotTried`] when the GPU
+21
View File
@@ -680,6 +680,27 @@ fn overlays_match() {
let req = RenderRequest { overlay: Overlay::Mask { id, view, color: [230, 30, 40], opacity: 50 }, ..RenderRequest::fit(640, 640) };
check(&format!("mask {id} overlay {}", view.name()), &src, &raw, &s, &req);
}
// The ⌥-drag views (issue #937): clipping and grey, on an edit that clips both ends.
let mut s = DevelopSettings::default();
typical(&mut s);
s.light.whites = 60.0;
s.light.blacks = -60.0;
check("grey overlay", &src, &raw, &s, &RenderRequest { overlay: Overlay::Grey, ..RenderRequest::fit(640, 640) });
// The clipping views test each channel for exactly 0 / 255 (both renderers draw them with
// `visualize::apply` on their 8-bit image): like Visualize Spots, a 1-LSB difference at the
// threshold flips a pixel, so compare the share of differing pixels, and that the views agree
// on what clips.
for (overlay, edge) in [(Overlay::ShadowClip, 0u8), (Overlay::HighlightClip, 255)] {
let req = RenderRequest { overlay, ..RenderRequest::fit(640, 640) };
let plain = render(&src, &raw, &s, &RenderRequest::fit(640, 640)).image;
let cpu = render(&src, &raw, &s, &req).image;
let gpu = lightcraft_gpu::render(&src, &raw, &s, &req, None).expect("gpu").image;
let differ = cpu.data.iter().zip(&gpu.data).filter(|(a, b)| a != b).count() as f64 / cpu.data.len() as f64;
let clipped = plain.data.iter().filter(|p| p[..3].contains(&edge)).count() as f64 / plain.data.len() as f64;
eprintln!("{overlay:<28?} clipped {:.3}% differing {:.4}%", clipped * 100.0, differ * 100.0);
assert!(clipped > 0.001, "{overlay:?}: the edit clips nothing to compare");
assert!(differ < 0.002, "{overlay:?}: {differ}");
}
}
#[test]
+33 -4
View File
@@ -5,7 +5,7 @@ use lightcraft_color::spline::{Lut1, MonotoneCurve};
use lightcraft_color::transfer::linear_to_srgb;
use lightcraft_develop::{DevelopSettings, LocalAdjustments, Process, ToneCurve, VignetteStyle};
use lightcraft_geom::Point;
use lightcraft_raster::Rgba8;
use lightcraft_raster::{Plane, Rgba8};
use crate::colorops::ColorOps;
use crate::geometry::Frame;
@@ -14,6 +14,36 @@ use crate::output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc}
use crate::tone::ToneMap;
use crate::{Prepared, SourceInfo, for_rows};
/// Sharpening's Masking as 0..1.
fn sharpen_masking(s: &DevelopSettings) -> f32 {
(s.detail.sharpen_masking / 100.0) as f32
}
/// How much sharpening applies at a pixel whose fine detail (log luminance minus its texture
/// blur) is `det`, with Masking `masking` (0..1): everywhere at 0, only on stronger edges above.
#[inline]
fn sharpen_weight(masking: f32, det: f32) -> f32 {
if masking > 0.0 { smooth(masking * 0.25, masking * 0.25 + 0.15, det.abs()) } else { 1.0 }
}
/// [`sharpen_weight`] at every pixel of `p` (the view [`crate::Overlay::SharpenMask`] shows).
pub(crate) fn sharpen_weight_plane(p: &Prepared, s: &DevelopSettings) -> Plane {
let masking = sharpen_masking(s);
if masking <= 0.0 {
return Plane::filled(p.log_l.width, p.log_l.height, 1.0);
}
// the detail band the render sharpens (made here when sharpening is off)
let fresh;
let blur = match &p.texture_blur {
Some(b) if (b.width, b.height) == (p.log_l.width, p.log_l.height) => &**b,
_ => {
fresh = lightcraft_raster::blur::gaussian(&p.log_l, crate::local::texture_sigma(p.px_per_long));
&fresh
}
};
p.log_l.zip_map(blur, |l, b| sharpen_weight(masking, l - b))
}
#[inline]
fn smooth(e0: f32, e1: f32, x: f32) -> f32 {
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
@@ -244,7 +274,7 @@ impl FinishParams {
tex,
dehaze,
sharpen: (s.detail.sharpen_amount / 150.0) as f32,
sharpen_mask: (s.detail.sharpen_masking / 100.0) as f32,
sharpen_mask: sharpen_masking(s),
air: air_pre * gain,
air_pre,
gain,
@@ -509,8 +539,7 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
let tame = 1.0 - 0.6 * smooth(0.4, 1.6, det.abs());
delta += tx * 1.1 * det.clamp(-1.0, 1.0) * tame;
if sp != 0.0 {
let m = if sharpen_mask > 0.0 { smooth(sharpen_mask * 0.25, sharpen_mask * 0.25 + 0.15, det.abs()) } else { 1.0 };
delta += sp * 1.3 * det.clamp(-0.8, 0.8) * m;
delta += sp * 1.3 * det.clamp(-0.8, 0.8) * sharpen_weight(sharpen_mask, det);
}
}
// local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges
+4 -1
View File
@@ -663,8 +663,11 @@ pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings,
}
/// The alpha plane a mask overlay shows: the one the render evaluated, or (for a hidden mask) a
/// fresh evaluation.
/// fresh evaluation. For [`Overlay::SharpenMask`], the weight sharpening gets at each pixel.
fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option<Plane> {
if o == Overlay::SharpenMask {
return Some(finish::sharpen_weight_plane(prep, &plan.settings));
}
let m = o.mask(&plan.settings)?;
if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) {
return Some(e.alpha.clone());
+6 -1
View File
@@ -488,6 +488,11 @@ pub struct PlaneSigmas {
pub chroma: Option<f32>,
}
/// Gaussian sigma of the texture / sharpening detail band at `px_per_long` pixels per long edge.
pub(crate) fn texture_sigma(px_per_long: f64) -> f32 {
(0.0018 * px_per_long as f32).max(0.6)
}
pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneSigmas {
let ppl = px_per_long as f32;
let local_any = |f: fn(&lightcraft_develop::LocalAdjustments) -> f64| s.masks.iter().any(|m| f(&m.adjust) != 0.0);
@@ -508,7 +513,7 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS
|| local_any(|a| a.sharpness)
|| local_any(|a| a.noise)
|| s.masks.iter().any(|m| m.adjust.defringe > 0.0))
.then(|| (0.0018 * ppl).max(0.6));
.then(|| texture_sigma(px_per_long));
let dark = (s.effects.dehaze != 0.0 || local_any(|a| a.dehaze)).then(|| (0.02 * ppl).max(1.0));
let chroma = (local_any(|a| a.moire) || s.masks.iter().any(|m| m.adjust.noise > 0.0)).then(|| (CHROMA_SIGMA * ppl).max(1.0));
let base_detail = if base.is_some() { tone_base_detail(s, px_per_long) } else { 0 };
+133 -7
View File
@@ -9,12 +9,20 @@
//! - **Mask overlay** (Masking): the evaluated alpha of one mask, drawn as a colour tint, a colour
//! tint on a black-and-white image, the image on black / white, or the alpha as white on black
//! ([`MaskView`]). Both renderers hand the same alpha plane (the one the render used) to [`apply`].
//! - **Clipping while ⌥-dragging** Blacks / Whites / Exposure ([`Overlay::ShadowClip`],
//! [`Overlay::HighlightClip`]): every output channel at 0 (or 255) shown, everything else blank,
//! so the clipped areas, and the channels that clip, are all that's left to see.
//! - **Sharpening mask while ⌥-dragging Masking** ([`Overlay::SharpenMask`]): the weight the
//! render gives sharpening at each pixel, white on black (the CPU renderer draws it: it needs the
//! detail planes).
//! - **Grey while ⌥-dragging** Sharpening or luminance Noise Reduction ([`Overlay::Grey`]): the
//! render in grey, so detail and noise are judged without colour.
use std::borrow::Cow;
use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020};
use lightcraft_color::transfer::srgb_to_linear;
use lightcraft_color::{REC2020, SRGB};
use lightcraft_color::{LUMA_709, REC2020, SRGB};
use lightcraft_develop::DevelopSettings;
use lightcraft_raster::{Plane, Rgba8};
@@ -39,6 +47,17 @@ pub enum Overlay {
/// Visualize HDR (photos edited in HDR): the SDR image dimmed to grey, with every pixel the HDR
/// rendition puts above SDR white painted by how far above it is ([`HDR_BANDS`]).
HdrRange,
/// Shadow clipping (⌥ on Blacks): white, black where every channel is 0, and where only some
/// are, the colour of the channels that aren't.
ShadowClip,
/// Highlight clipping (⌥ on Whites / Exposure): black, white where every channel is 255, and
/// where only some are, the colour of those channels.
HighlightClip,
/// Sharpening's weight after Masking (⌥ on Masking): white where it sharpens, black where
/// Masking holds it back.
SharpenMask,
/// The render in grey (⌥ on the Sharpening and luminance Noise Reduction sliders).
Grey,
}
/// [`Overlay::HdrRange`] colours (sRGB) for 0–1, 1–2, 2–3 and ≥ 3 stops above SDR white.
@@ -125,6 +144,10 @@ impl Overlay {
Overlay::Mask { id, view, color, opacity } => (3, pack_mask(id, view, color, opacity) as f64),
Overlay::AllMasks { view, color, opacity } => (5, pack_mask(0, view, color, opacity) as f64),
Overlay::HdrRange => (4, 0.0),
Overlay::ShadowClip => (6, 0.0),
Overlay::HighlightClip => (7, 0.0),
Overlay::SharpenMask => (8, 0.0),
Overlay::Grey => (9, 0.0),
}
}
@@ -143,6 +166,10 @@ impl Overlay {
opacity: ((v as u64 >> 44) & 0x7f).min(100) as u8,
}
}
6 => Overlay::ShadowClip,
7 => Overlay::HighlightClip,
8 => Overlay::SharpenMask,
9 => Overlay::Grey,
_ => Overlay::None,
}
}
@@ -156,9 +183,20 @@ impl Overlay {
Overlay::Mask { id, view, color, opacity } => 3 << 60 | pack_mask(id, view, color, opacity),
Overlay::AllMasks { view, color, opacity } => 5 << 60 | pack_mask(0, view, color, opacity),
Overlay::HdrRange => 0x4000,
Overlay::ShadowClip => 0x5000,
Overlay::HighlightClip => 0x6000,
Overlay::SharpenMask => 0x7000,
Overlay::Grey => 0x8000,
}
}
/// The ⌥-drag views that show none of the photo's colours: the clipping views (which test the
/// sRGB values the histogram counts) and the grey ones. A display render draws them in sRGB,
/// then converts, so their histogram (taken before the overlay) is the photo's sRGB one.
pub fn renders_in_srgb(self) -> bool {
matches!(self, Overlay::ShadowClip | Overlay::HighlightClip | Overlay::SharpenMask | Overlay::Grey)
}
/// The mask an [`Overlay::Mask`] shows (if it exists in `s` and has components).
pub fn mask(self, s: &DevelopSettings) -> Option<&lightcraft_develop::Mask> {
match self {
@@ -181,12 +219,21 @@ pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) {
}
/// Draw overlay `o` over `img` (rendered with `plan`). `mask` is the evaluated alpha of the mask
/// an [`Overlay::Mask`] shows, at the image's size (see [`Overlay::mask`]); `masks` is the set of
/// alphas an [`Overlay::AllMasks`] shows, one per visible mask with components.
/// an [`Overlay::Mask`] shows, or the sharpening weight of an [`Overlay::SharpenMask`], at the
/// image's size (see [`Overlay::mask`]); `masks` is the set of alphas an [`Overlay::AllMasks`]
/// shows, one per visible mask with components.
pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>, masks: Option<&[Plane]>) {
match o {
// drawn by the renderer from the HDR rendition ([`hdr_range`])
Overlay::None | Overlay::HdrRange => {}
Overlay::ShadowClip => clipping(img, false),
Overlay::HighlightClip => clipping(img, true),
Overlay::Grey => grey(img),
Overlay::SharpenMask => {
if let Some(a) = mask.filter(|a| (a.width, a.height) == (img.width, img.height)) {
mask_view(img, a, MaskView::WhiteOnBlack, [255; 3], 100);
}
}
Overlay::PointColorRange(i) => {
if let Some(p) = plan.settings.point_colors.get(i as usize) {
point_color_range(img, &PointK::new(p));
@@ -224,7 +271,7 @@ pub fn mask_view(img: &mut Rgba8, a: &Plane, view: MaskView, color: [u8; 3], opa
for (x, px) in row.iter_mut().enumerate() {
let a = a.data[y * w + x].clamp(0.0, 1.0);
let p = [px[0] as f32, px[1] as f32, px[2] as f32];
let grey = 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2];
let grey = luma8(*px);
let out: [f32; 3] = match view {
MaskView::Color => std::array::from_fn(|c| p[c] + (col[c] - p[c]) * a * op),
MaskView::ColorOnBw => std::array::from_fn(|c| grey + (col[c] - grey) * a * op),
@@ -238,6 +285,25 @@ pub fn mask_view(img: &mut Rgba8, a: &Plane, view: MaskView, color: [u8; 3], opa
});
}
/// Rec. 709 luma of an 8-bit pixel (0..255).
fn luma8(p: [u8; 4]) -> f32 {
LUMA_709[0] * p[0] as f32 + LUMA_709[1] * p[1] as f32 + LUMA_709[2] * p[2] as f32
}
/// The clipping view: with `high`, each channel at 255 on, the rest off (black where nothing
/// clips); otherwise each channel at 0 off, the rest on (white where nothing clips).
fn clipping(img: &mut Rgba8, high: bool) {
let on = move |v: u8| if (high && v == 255) || (!high && v != 0) { 255 } else { 0 };
img.map_in_place(|p| [on(p[0]), on(p[1]), on(p[2]), 255]);
}
fn grey(img: &mut Rgba8) {
img.map_in_place(|p| {
let g = luma8(p).round().clamp(0.0, 255.0) as u8;
[g, g, g, 255]
});
}
/// Blur radius (Gaussian sigma) of the spot view, as a fraction of the long edge.
const SPOT_SIGMA: f64 = 0.006;
@@ -255,7 +321,7 @@ fn spots(img: &mut Rgba8, t: u8, ppl: f64) {
}
let l = lightcraft_raster::Plane::from_fn(w, h, |x, y| {
let p = img.data[y * w + x];
(0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) / 255.0
luma8(p) / 255.0
});
let b = lightcraft_raster::blur::gaussian(&l, (SPOT_SIGMA * ppl).max(0.8) as f32);
let thr = spot_threshold(t);
@@ -275,7 +341,7 @@ fn point_color_range(img: &mut Rgba8, k: &PointK) {
let c = std::array::from_fn(|r| m[r][0] * lin[0] + m[r][1] * lin[1] + m[r][2] * lin[2]);
let [l, ch, h] = lab_to_lch(oklab_from_2020(c));
let a = k.weight(l, ch, h);
let grey = 0.2126 * px[0] as f32 + 0.7152 * px[1] as f32 + 0.0722 * px[2] as f32;
let grey = luma8(*px);
for c in 0..3 {
px[c] = (grey + (px[c] as f32 - grey) * a).round().clamp(0.0, 255.0) as u8;
}
@@ -296,7 +362,7 @@ pub fn hdr_range(img: &mut Rgba8, hdr: &crate::DeepImage, luma: [f32; 3]) {
let i = (y * w + x) * 3;
let Some(c) = v.get(i..i + 3) else { continue };
let lum = luma[0] * c[0] + luma[1] * c[1] + luma[2] * c[2];
let grey = (0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) * 0.55;
let grey = luma8(*p) * 0.55;
if lum > 1.0 {
let stops = lum.log2();
let band = HDR_BANDS[(stops as usize).min(HDR_BANDS.len() - 1)];
@@ -343,6 +409,66 @@ mod tests {
assert_eq!(Overlay::from_parts(k, v), Overlay::Spots(37));
}
#[test]
fn clipping_views_show_only_the_clipped_channels() {
// left to right: black, a red that clips only in red, mid grey, white
let src = Rgb32f::from_fn(40, 10, |x, _| match x / 10 {
0 => [0.0; 3],
1 => [0.9, 0.02, 0.02],
2 => [0.18; 3],
_ => [8.0; 3],
});
let (info, s) = (SourceInfo::default(), DevelopSettings::default());
let shot = |o: Overlay| render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(40, 10) });
let plain = shot(Overlay::None).image;
let at = |i: &Rgba8, x: usize| i.data[5 * 40 + x];
assert_eq!((at(&plain, 5), at(&plain, 35)), ([0, 0, 0, 255], [255; 4]), "the scene clips at both ends");
assert_eq!(at(&plain, 15)[0], 255, "the red clips in red");
let hi = shot(Overlay::HighlightClip);
assert_eq!([5, 15, 25, 35].map(|x| at(&hi.image, x)), [[0, 0, 0, 255], [255, 0, 0, 255], [0, 0, 0, 255], [255; 4]]);
let lo = shot(Overlay::ShadowClip);
assert_eq!(at(&lo.image, 5), [0, 0, 0, 255], "black where every channel clips");
assert_eq!([25, 35].map(|x| at(&lo.image, x)), [[255; 4]; 2], "white where none does");
// only the view changes: the histogram is the photo's
assert_eq!(hi.histogram.clipping(), shot(Overlay::None).histogram.clipping());
for o in [Overlay::ShadowClip, Overlay::HighlightClip, Overlay::SharpenMask, Overlay::Grey] {
let (k, v) = o.to_parts();
assert_eq!(Overlay::from_parts(k, v), o);
assert_ne!(o.key(), Overlay::None.key());
}
}
#[test]
fn sharpen_mask_view_follows_masking() {
// a hard vertical edge between two flat halves
let src = Rgb32f::from_fn(200, 100, |x, _| if x < 100 { [0.05; 3] } else { [0.4; 3] });
let info = SourceInfo::default();
let mut s = DevelopSettings::default();
s.detail.sharpen_amount = 60.0;
let shot =
|s: &DevelopSettings| render(&src, &info, s, &RenderRequest { overlay: Overlay::SharpenMask, ..RenderRequest::fit(200, 100) }).image;
let at = |i: &Rgba8, x: usize| i.data[50 * 200 + x][0];
// Masking 0: sharpening applies everywhere
let open = shot(&s);
assert!(open.data.iter().all(|p| *p == [255; 4]), "all white at Masking 0");
// Masking 100: only the edge is sharpened
s.detail.sharpen_masking = 100.0;
let edge = shot(&s);
assert_eq!((at(&edge, 20), at(&edge, 180)), (0, 0), "flat areas held back");
assert!(at(&edge, 100).max(at(&edge, 99)) > 200, "the edge sharpened: {}", at(&edge, 100));
// the mask shows with sharpening off too
s.detail.sharpen_amount = 0.0;
assert_eq!(shot(&s).data, edge.data);
}
#[test]
fn grey_view_drops_the_colour() {
let src = Rgb32f::from_fn(8, 8, |_, _| [0.4, 0.1, 0.05]);
let req = RenderRequest { overlay: Overlay::Grey, ..RenderRequest::fit(8, 8) };
let v = render(&src, &SourceInfo::default(), &DevelopSettings::default(), &req).image;
assert!(v.data.iter().all(|p| p[0] == p[1] && p[1] == p[2] && p[0] > 0));
}
#[test]
fn point_color_range_keeps_the_selected_colour_only() {
// left half orange, right half blue
+84 -4
View File
@@ -2694,12 +2694,14 @@ mod tests {
h.request("ui.key", json!({"key": "O", "shift": true}), t);
assert_eq!(h.app.ui.crop_overlay_orient, 1);
assert_eq!(h.app.ui.mask_overlay, mask_overlay, "O doesn't toggle the mask overlay while cropping");
let locked = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().crop.aspect.is_some();
assert!(!locked(&h));
h.request("ui.key", json!({"key": "A"}), t);
// an untouched photo starts locked to its own shape (#991): A unlocks, A again locks
let locked = |h: &Headless| h.app.crop_tool_aspect().is_some();
assert!(locked(&h));
h.request("ui.key", json!({"key": "A"}), t);
assert!(!locked(&h));
assert_eq!(h.app.ui.crop_default_aspect, Some(json!("free")));
h.request("ui.key", json!({"key": "A"}), t);
assert!(locked(&h));
assert!(!h.app.ui.visualize_spots);
}
@@ -2881,15 +2883,56 @@ mod tests {
assert!(restore[1] >= 0.0 && restore[1] + restore[3] <= f64::from(size[1]), "the restore row is in view: {restore:?} {after_ids:?}");
assert!(h.app.session.browse.is_some(), "hiding does not stop browsing");
assert!(dir.is_dir(), "the folder itself is untouched");
// a second hidden place: the row lists them, and each comes back on its own (issue #2114:
// the row used to bring every hidden place back at once)
let dir2 = std::env::temp_dir().join(format!("lc-ui-hide-{}-b", std::process::id()));
let _ = std::fs::remove_dir_all(&dir2);
std::fs::create_dir_all(&dir2).unwrap();
let path2 = dir2.to_string_lossy().to_string();
for (command, params) in [("local.addRoot", json!({"path": path2})), ("local.hide", json!({"path": path2}))] {
let r = h.request("engine.execute", json!({"command": command, "params": params}), t);
assert_eq!(r["ok"], true, "{r}");
}
h.settle(SETTLE);
let menu = |h: &mut Headless| -> Vec<String> {
let w = h.request("ui.widgets", json!({"filter": "local:restoreHidden:"}), t);
w["result"].as_array().unwrap().iter().map(|x| x["id"].as_str().unwrap().to_string()).collect()
};
assert!(menu(&mut h).is_empty(), "the list opens from the row");
assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true);
h.settle(SETTLE);
assert!(h.app.ui.hidden_locations.is_empty());
let items = menu(&mut h);
assert!(items.contains(&format!("local:restoreHidden:{path}")) && items.contains(&"local:restoreHidden:all".to_string()), "{items:?}");
assert_eq!(h.request("ui.clickWidget", json!({"id": format!("local:restoreHidden:{path}")}), t)["ok"], true);
h.settle(SETTLE);
assert!(!h.app.ui.hidden_locations.iter().any(|x| x == &path), "{:?}", h.app.ui.hidden_locations);
assert!(h.app.ui.hidden_locations.iter().any(|x| x == &path2), "the other place stays hidden");
assert!(ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "still something to restore");
// two hidden again: Restore All brings every one back
let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert!(h.app.ui.hidden_locations.len() >= 2, "{:?}", h.app.ui.hidden_locations);
assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true);
h.settle(SETTLE);
assert_eq!(h.request("ui.clickWidget", json!({"id": "local:restoreHidden:all"}), t)["ok"], true);
h.settle(SETTLE);
assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations);
assert!(!ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "nothing left to restore");
// one hidden: the row brings it back itself, no list
let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path2}}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true);
h.settle(SETTLE);
assert!(menu(&mut h).is_empty(), "no list for a single hidden place");
assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations);
if !hidden_before {
// (with Home hidden for this test, the folder is back inside Home's tree instead)
assert!(ids(&mut h).contains(&format!("source:local:{path}")));
}
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&dir2);
}
/// Local: a kept folder stays a root while its subfolders (and other locations) are
@@ -4433,6 +4476,43 @@ mod tests {
h.settle(SETTLE);
}
/// A hover preview gets the loupe's display fit: it is the size the photo is drawn at, as the
/// loupe render is, not a preview-level render the GPU shrinks.
#[test]
fn a_hover_preview_is_fitted_to_the_drawn_size() {
use crate::render::Slot;
let mut h = demo([1400.0, 900.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail"}), t);
h.request("engine.execute", json!({"command": "panel.profiles"}), t);
h.settle(SETTLE);
h.step_until(SETTLE, |h| !h.app.renderer.is_pending(Slot::Main));
let r = h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t);
assert_eq!(r["ok"], true, "{r}");
h.step_until(SETTLE, |h| h.app.loupe_shown.map(|l| l.1) == Some("hover"));
let size = |s: Slot| h.app.renderer.textures.get(&s).map(|t| t.size);
let (main, hover) = (size(Slot::Main).expect("loupe render"), size(Slot::Hover).expect("hover render"));
assert!(main[0].max(main[1]) < lightcraft_engine::SourceLevel::Preview.max_edge(), "the loupe render is fitted: {main:?}");
assert!(hover[0].abs_diff(main[0]) <= 1 && hover[1].abs_diff(main[1]) <= 1, "hover {hover:?} vs loupe {main:?}");
}
/// Zoomed in under a window render, the hidden whole-frame render stays canvas-sized instead of
/// rising to the preview level.
#[test]
fn a_zoomed_view_keeps_its_whole_frame_render_canvas_sized() {
use crate::render::Slot;
let mut h = demo([1000.0, 700.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail", "right": "none"}), t);
h.settle(SETTLE);
h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t);
h.settle(SETTLE);
assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100.0));
h.step_until(SETTLE, |h| !h.app.renderer.is_pending(Slot::Main));
let main = h.app.renderer.textures.get(&Slot::Main).expect("whole-frame render").size;
assert!(main[0].max(main[1]) < lightcraft_engine::SourceLevel::Preview.max_edge(), "{main:?}");
}
#[test]
fn trackpad_navigation_keeps_the_image_cursor_between_events() {
let mut h = demo([1000.0, 700.0]);
+11
View File
@@ -37,6 +37,9 @@ pub enum Icon {
StarFilled,
Circle,
FlagPick,
/// The Pick flag a photo carries: pennant filled, so a pick reads at a glance (#1013). The
/// outlined [`Icon::FlagPick`] stays for buttons and filters.
FlagPickFilled,
FlagReject,
Gear,
Filmstrip,
@@ -387,6 +390,14 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option<
pen.line(&[(5.0, 17.0), (5.0, 3.5)]);
pen.closed(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)]);
}
FlagPickFilled => {
pen.line(&[(5.0, 17.0), (5.0, 3.5)]);
// the notched pennant is concave: two convex halves meet at the notch, overlapping
// along the pole side so their anti-aliased edges leave no seam
pen.fill(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (5.0, 7.5)], color);
pen.fill(&[(5.0, 6.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)], color);
pen.closed(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)]);
}
FlagReject => {
pen.line(&[(5.0, 17.0), (5.0, 3.5)]);
pen.closed(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)]);
+12 -3
View File
@@ -54,6 +54,8 @@ mod tests_album_picker;
#[cfg(test)]
mod tests_album_remove;
#[cfg(test)]
mod tests_alt_preview;
#[cfg(test)]
mod tests_appearance;
#[cfg(test)]
mod tests_before_after;
@@ -140,6 +142,8 @@ mod tests_preview_limit;
#[cfg(test)]
mod tests_print;
#[cfg(test)]
mod tests_profile_menu;
#[cfg(test)]
mod tests_profiles_layout;
#[cfg(test)]
mod tests_quit_unsaved;
@@ -696,7 +700,10 @@ impl LightcraftApp {
if d.crop != lightcraft_develop::Crop::default() {
return d.crop.aspect;
}
match self.ui.crop_default_aspect.as_ref()? {
// nothing chosen yet: locked to the photo's own shape, as Lightroom starts (#991); one
// Lock toggle (remembered as "free") unlocks it for good
let remembered = self.ui.crop_default_aspect.clone().unwrap_or_else(|| Value::String("original".into()));
match &remembered {
Value::String(s) if s == "original" => {
let p = self.session.catalog.photo(photo)?;
let (w, h) = (p.width.max(1).saturating_mul(100), p.height.max(1).saturating_mul(100));
@@ -715,11 +722,13 @@ impl LightcraftApp {
/// Called only after a crop gesture begins, inside its undo transaction.
pub(crate) fn begin_crop(&mut self) -> Result<(), String> {
// nothing chosen yet: the photo's own shape, as `crop_tool_aspect` shows it
let aspect = self.ui.crop_default_aspect.clone().unwrap_or_else(|| serde_json::json!("original"));
if self.session.interaction.is_some()
&& self.session.active().and_then(|id| self.session.develop_of(id)).is_some_and(|d| d.crop == lightcraft_develop::Crop::default())
&& let Some(aspect) = self.ui.crop_default_aspect.clone()
&& aspect != serde_json::json!("free")
&& let Err(e) = self.session.execute("crop.aspect", &serde_json::json!({"aspect": aspect}))
// the remembered ratio, turned like the new photo's crop (as a named ratio is)
&& let Err(e) = self.session.execute("crop.aspect", &serde_json::json!({"aspect": aspect, "orientation": "crop"}))
{
// A remembered value the command refuses (a hand-edited settings file) would refuse
// every crop drag on an untouched photo: forget it and let this drag go ahead.
+2
View File
@@ -132,6 +132,8 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to:
} else {
t.icon
};
// the Pick button shows the flag the photo carries: filled while it is a pick
let icon = if on && f == Flag::Pick { Icon::FlagPickFilled } else { icon };
paint(pill_ui.painter(), r.shrink(3.0), icon, c);
if resp.clicked() {
let mut params = json!({"flag": match f { Flag::Pick => "pick", Flag::Reject => "reject", Flag::None => "none" }});
+1 -1
View File
@@ -345,7 +345,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo
x += 5.0 * 13.0 + 4.0;
let fr = Rect::from_min_size(pos2(x, cap.center().y - 7.0), vec2(14.0, 14.0));
match photo.flag {
Flag::Pick if fr.right() < cap.right() => paint(pt, fr, Icon::FlagPick, t.pick),
Flag::Pick if fr.right() < cap.right() => paint(pt, fr, Icon::FlagPickFilled, t.pick),
Flag::Reject if fr.right() < cap.right() => paint(pt, fr, Icon::FlagReject, t.reject),
_ => {}
}
+136 -18
View File
@@ -145,6 +145,21 @@ pub(crate) fn fit_texture_rect(area: Rect, size: [usize; 2]) -> Rect {
Rect::from_center_size(area.center(), vec2(w * scale, h * scale))
}
/// `rect` moved onto whole physical pixels, at exactly the texture's size, when the texture is
/// already the size it is drawn at (within half a pixel), as a display-fitted render at Fit is: centred
/// on a fractional position, the GPU's bilinear filter would average every screen pixel with its
/// neighbour (the 100 % view does the same in [`fit_rect`]). Textures of other sizes are left to
/// the sampler.
pub(crate) fn snap_fit_rect(rect: Rect, size: [usize; 2], ppp: f32) -> Rect {
let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 };
let (w, h) = (size[0] as f32, size[1] as f32);
if (rect.width() * ppp - w).abs() > 0.5 || (rect.height() * ppp - h).abs() > 0.5 {
return rect;
}
let snap = |v: f32| (v * ppp).round() / ppp;
Rect::from_min_size(pos2(snap(rect.min.x), snap(rect.min.y)), vec2(w / ppp, h / ppp))
}
/// Keep the viewport inside the photo; axes smaller than the viewport stay centred.
fn bounded_pan(area: Rect, size: egui::Vec2, pan: (f32, f32)) -> (f32, f32) {
let bound = |visible: f32, image: f32, centre: f32| {
@@ -320,11 +335,11 @@ fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect {
/// Whether an overlay can be rendered on a region tile without changing what the tile means.
/// Mask overlays (one mask, or every visible mask at once) are evaluated from the same mask
/// alphas as the full render; the other diagnostic overlays either need whole-frame context or
/// deliberately replace the loupe.
/// alphas as the full render, and the clipping and grey views work per pixel of it; the other
/// diagnostic overlays need whole-frame context or deliberately replace the loupe.
pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> bool {
use lightcraft_pipeline::Overlay;
matches!(overlay, Overlay::None | Overlay::Mask { .. } | Overlay::AllMasks { .. })
matches!(overlay, Overlay::None | Overlay::Mask { .. } | Overlay::AllMasks { .. } | Overlay::ShadowClip | Overlay::HighlightClip | Overlay::Grey)
}
/// What a window render of the loupe needs to know about the view it belongs to.
@@ -522,7 +537,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
app.image_rect = Some(img_rect);
// request renders: the loupe at display resolution (drafts during drags)
let interacting = app.session.interaction.is_some();
let overlay = view_overlay(app, &d);
let overlay = view_overlay(app, &d, ui.input(|i| i.modifiers.alt && i.pointer.primary_down()));
// a drag drafts at full size where the GPU draws the view (by the GPU's own rule, `handles`;
// `ready` first: `available` would wait for a device still being created)
let gpu = interacting
@@ -531,7 +546,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
&& lightcraft_engine::gpu::available();
let shown = app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id && t.quick.is_none()).map(|t| (t.key, t.ms));
let drag = app.session.interaction.as_ref().map(|i| (i.photo, i.label.as_str()));
let draft = app.drag_scale.scale(drag, gpu, shown.map(|(key, _)| key));
let now = ui.input(|i| i.time);
let draft = app.drag_scale.scale(drag, gpu, shown.map(|(key, _)| key), now);
// render at the final size: a click-zoom animation only changes how the result is drawn
let native_long = native[0].max(native[1]);
let drawn_long = target_rect.width().max(target_rect.height()) * ppp;
@@ -553,23 +569,46 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
plan = crate::region::plan(&app.ui.settings, crate::region::ViewSizes { windows: false, ..sizes });
}
// time the full-size drafts where they are what shows: under a window the whole-frame draft
// is small anyway, and the frame's time is the window's upload
if let Some((key, ms)) = shown.filter(|_| plan.window_edge.is_none()) {
// is small anyway, and the frame's time is the window's upload. A view the GPU doesn't draw
// (⌥ pressed mid-drag) renders on the CPU and says nothing about the GPU drafts.
if let Some((key, ms)) = shown.filter(|_| plan.window_edge.is_none() && gpu) {
app.drag_scale.observe(key, ms, app.perf.host_frame_ms);
}
// a pinch changes the zoom every frame: keep the sizes of before it until it is quiet
let now = ui.input(|i| i.time);
let plan = app.size_hold.apply(id, now, navigating, plan);
if app.size_hold.holding(now) {
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(crate::region::HOLD_SECS));
}
let want = plan.main_edge;
let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) };
// At rest the whole frame renders at no less than the preview source level: the source is then
// copied 1:1 (no resample in the pipeline, whose prefilter and bilinear pass soften detail near
// the output's limit) and shrunk once, by the Lanczos display fit below.
// (zoomed in under a window render, the whole frame is hidden: it stays canvas-sized)
let want = if interacting || plan.window_edge.is_some() {
plan.main_edge
} else {
// (a user's explicit Preview size below the preview level still caps it)
let limit = if app.ui.settings.preview_limit == 0 { usize::MAX } else { app.ui.settings.preview_limit as usize };
let floor = lightcraft_engine::SourceLevel::Preview.max_edge().min(limit).min(native_long);
plan.main_edge.max(floor).min(texture_side.max(crate::state::MIN_TEXTURE_SIDE))
};
// The short side is rounded, not truncated: a truncated box (2560 x 1706 for a 2560 x 1707
// source) made the render a slightly anisotropic resample of its source, a 2-tap blur along
// one axis only. A box that matches the source to rounding is rendered at exactly its size.
let short = |e: usize, a: f32| ((e as f32 / a).round() as usize).max(1);
let (rw, rh) = if aspect >= 1.0 { (want, short(want, aspect)) } else { (short(want, 1.0 / aspect), want) };
// hover and before renders are stand-ins: the preview size is plenty and keeps them cheap
let se = app.ui.settings.stand_in_edge(want, texture_side);
let (sw, sh) = if aspect >= 1.0 { (se, (se as f32 / aspect) as usize) } else { ((se as f32 * aspect) as usize, se) };
let (sw, sh) = if aspect >= 1.0 { (se, short(se, aspect)) } else { (short(se, 1.0 / aspect), se) };
if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) {
let job = if interacting { job.draft() } else { job };
// at rest the render is shrunk to exactly the size it is drawn at with a Lanczos filter on
// the worker, so the window draws it texel for texel instead of shrinking it with 2-tap
// sampling (the render is up to a 64 px step larger, or the preview level)
let job = if interacting {
job
} else {
job.with_display_fit((target_rect.width() * ppp).round() as usize, (target_rect.height() * ppp).round() as usize)
};
let job = job.with_overlay(overlay).with_proof(app.ui.soft_proof.then_some(app.ui.proof));
let job = job.with_hidden_grading(app.ui.grading_hidden_for(id));
app.renderer.request(Slot::Main, job, 100);
@@ -587,6 +626,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
// once it is ready (nothing is committed)
let hover_key = match app.hover_preview.clone() {
Some(h) if !interacting => app.session.preview_job(id, sw.max(8), sh.max(8), !crop_tool, &h.settings).map(|job| {
// (the same display fit as the After view, drawn texel for texel like it)
let job = job.with_display_fit((target_rect.width() * ppp).round() as usize, (target_rect.height() * ppp).round() as usize);
let key = job.key;
app.renderer.request(Slot::Hover, job, 105);
(key, h.label)
@@ -625,6 +666,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if (split || show_before || split_view)
&& let Some(job) = app.session.render_job(id, sw.max(8), sh.max(8), true, !crop_tool)
{
// (the same display fit as the After view, so both panes are the same texture size)
let job = if interacting {
job
} else {
job.with_display_fit((target_rect.width() * ppp).round() as usize, (target_rect.height() * ppp).round() as usize)
};
app.renderer.request(Slot::Before, job, 90);
}
// zoomed past what the whole-frame render holds: also render the part on screen, at no more
@@ -692,7 +739,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
} else {
return "none";
};
let image_rect = fit_texture_rect(r, tex.size);
let image_rect = snap_fit_rect(fit_texture_rect(r, tex.size), tex.size, ppp);
p.with_clip_rect(clip).image(tex.tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
what
};
@@ -715,14 +762,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
p.text(label_at(img_rect, main_area), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim);
} else if show_before {
shown = draw(Slot::Before, img_rect, canvas);
p.text(pos2(img_rect.left() + 8.0, img_rect.top() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text);
// on the photo itself, so it gets the hover label's dark pill to stay readable over light pictures
let g = p.layout_no_wrap(crate::i18n::tr("Before").to_string(), t.font(12.0), Color32::WHITE);
let bg = label_badge_rect(img_rect, g.size());
p.rect_filled(bg, 4.0, Color32::from_black_alpha(160));
p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE);
} else if let Some((_key, label)) = &hover_key
&& let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id)
{
let hover_rect = fit_texture_rect(img_rect, tex.size);
p.image(tex.tex.id(), hover_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
let g = p.layout_no_wrap(label.clone(), t.font(12.0), Color32::WHITE);
let bg = Rect::from_min_size(hover_rect.left_top() + vec2(8.0, 8.0), g.size() + vec2(16.0, 8.0));
let bg = label_badge_rect(hover_rect, g.size());
p.rect_filled(bg, 4.0, Color32::from_black_alpha(160));
p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE);
shown = "hover";
@@ -910,6 +961,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
});
}
/// The dark pill behind a label drawn over the photo (`Before`, a hovered preset's name): at the
/// photo's top left, inset 8 points, with the `text` size plus padding.
fn label_badge_rect(photo: Rect, text: egui::Vec2) -> Rect {
Rect::from_min_size(photo.left_top() + vec2(8.0, 8.0), text + vec2(16.0, 8.0))
}
/// The info overlay at the canvas' top left (`view.infoOverlay`): file name with the capture date
/// and size, or with the camera and exposure.
fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &lightcraft_catalog::Photo) {
@@ -1342,13 +1399,42 @@ fn targeted_drag(app: &mut LightcraftApp, resp: &egui::Response, map: &CanvasMap
}
}
/// The diagnostic overlay the loupe shows (the selected mask, Point Color's visualized range,
/// The view holding ⌥ (Option) while dragging slider `control` shows in the loupe, as in Lightroom
/// (issue #937): clipping on Blacks (shadows) and Whites / Exposure (highlights), the sharpening
/// mask on Masking, the image in grey on the other Sharpening and the luminance Noise Reduction
/// sliders. Releasing ⌥ or the slider brings the photo back; nothing is saved.
pub(crate) fn slider_preview(control: &str) -> Option<lightcraft_pipeline::Overlay> {
SLIDER_PREVIEWS.iter().find(|(id, _)| *id == control).map(|(_, o)| *o)
}
/// The controls with a [`slider_preview`], and the view each shows.
pub(crate) const SLIDER_PREVIEWS: [(&str, lightcraft_pipeline::Overlay); 10] = {
use lightcraft_pipeline::Overlay::{Grey, HighlightClip, ShadowClip, SharpenMask};
[
("light.blacks", ShadowClip),
("light.whites", HighlightClip),
("light.exposure", HighlightClip),
("detail.sharpenMasking", SharpenMask),
("detail.sharpenAmount", Grey),
("detail.sharpenRadius", Grey),
("detail.sharpenDetail", Grey),
("detail.nrLuminance", Grey),
("detail.nrDetail", Grey),
("detail.nrContrast", Grey),
]
};
/// The diagnostic overlay the loupe shows (the ⌥-drag preview of the slider being dragged while
/// `alt`: ⌥ is held with the button down; the selected mask, Point Color's visualized range,
/// Visualize Spots).
pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay {
pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings, alt: bool) -> lightcraft_pipeline::Overlay {
use lightcraft_pipeline::{MaskView, Overlay};
if app.ui.fullscreen {
return Overlay::None;
}
if alt && let Some(o) = app.ui.dragging_control.as_deref().and_then(slider_preview) {
return o;
}
// Show every visible mask's region at once (Ctrl+Shift+O / Cmd+Shift+O), ahead of the selected-mask overlay
// so the shortcut is not shadowed by the always-on single-mask overlay.
if app.ui.right == RightPanel::Masking && app.ui.mask_overlay_all && d.masks.iter().any(|m| m.visible && !m.components.is_empty()) {
@@ -2698,7 +2784,7 @@ fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog:
}
x += ph.rating as f32 * 9.0 + 2.0;
match ph.flag {
Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPick, t.pick),
Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPickFilled, t.pick),
Flag::Reject => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagReject, t.reject),
Flag::None => {}
}
@@ -2709,10 +2795,21 @@ fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog:
#[cfg(test)]
mod preview_geometry_tests {
use super::{fit_rect, fit_texture_rect};
use super::{fit_rect, fit_texture_rect, label_badge_rect};
use crate::state::Zoom;
use egui::{Rect, pos2, vec2};
/// The "Before" label sits on a dark pill that wraps the text with padding on every side.
#[test]
fn before_label_badge_wraps_the_text() {
let text = vec2(40.0, 14.0);
let photo = Rect::from_min_size(pos2(20.0, 30.0), vec2(400.0, 300.0));
let bg = label_badge_rect(photo, text);
assert_eq!(bg.min, pos2(28.0, 38.0));
assert_eq!(bg.size(), vec2(56.0, 22.0));
assert!(photo.contains_rect(bg));
}
#[test]
fn portrait_preview_keeps_its_ratio_inside_a_landscape_frame() {
let frame = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0));
@@ -3052,6 +3149,27 @@ mod pen_tests {
}
}
#[cfg(test)]
mod snap_tests {
use super::*;
/// A texture drawn at its own size lands on whole physical pixels at exactly that size (no
/// bilinear blur); a texture of another size (zoomed, a stand-in) keeps the rect it was given.
#[test]
fn a_fitted_texture_is_drawn_on_whole_pixels_at_its_own_size() {
let r = Rect::from_min_size(pos2(394.3, 130.5), vec2(1088.4, 720.2));
let s = snap_fit_rect(r, [1088, 720], 1.0);
assert_eq!((s.min.x, s.min.y, s.width(), s.height()), (394.0, 131.0, 1088.0, 720.0));
// at 1.5 points per pixel the snap is to physical pixels and the size stays in points
let hi = snap_fit_rect(Rect::from_min_size(pos2(100.3, 50.2), vec2(725.4, 480.1)), [1088, 720], 1.5);
assert!(((hi.min.x * 1.5).fract()).abs() < 1e-3 && (hi.width() - 1088.0 / 1.5).abs() < 1e-3, "{hi:?}");
let other = snap_fit_rect(r, [2560, 1694], 1.0);
assert_eq!(other, r);
let bad = snap_fit_rect(r, [1088, 720], f32::NAN);
assert_eq!((bad.width(), bad.height()), (1088.0, 720.0));
}
}
#[cfg(test)]
mod filter_pill_tests {
use super::*;
+28 -11
View File
@@ -169,7 +169,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
// content, and the sliders below would run past its edge
let max = ui.available_width() - 28.0 - ui.spacing().item_spacing.x;
let r = crate::widgets::dropdown_within(ui, "profile", crate::i18n::profile_label(&d.profile.id, name), t.font(15.0), t.text_label, max);
egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d));
let below = r.rect.bottom();
egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d, below));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::widgets::icon_button(
ui,
@@ -429,8 +430,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
/// The profile dropdown: Favorites, Recent, one submenu per group, then favourite toggle and
/// Browse….
fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) {
/// Browse…. Like the menu bar's menus, the dropdown and each submenu stay inside the window and
/// scroll when their rows don't fit (issue #2059: a folder of many imported profiles ran past the
/// window's bottom edge). `below` is the dropdown's bottom edge, which the menu hangs from.
fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, below: f32) {
crate::menu_level::level(ui, 1, Some(below), |ui| profile_menu_rows(app, ui, d, below));
}
fn profile_menu_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, below: f32) {
use lightcraft_engine::presets::{PROFILES, profile_groups};
let t = Tokens::get(ui.ctx());
ui.set_min_width(200.0);
@@ -463,12 +470,17 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
}
for g in profile_groups() {
let r = crate::widgets::menu_button(ui, crate::i18n::tr(g), |ui| {
ui.set_min_width(170.0);
for p in PROFILES.iter().filter(|p| p.group == g) {
item(ui, "group", p.id, p.name, &mut pick);
}
crate::menu_level::level(ui, 2, Some(below), |ui| {
ui.set_min_width(170.0);
for p in PROFILES.iter().filter(|p| p.group == g) {
item(ui, "group", p.id, p.name, &mut pick);
}
});
});
register(ui.ctx(), format!("profileMenu:groupMenu:{g}"), r.response.rect);
if r.inner.is_some() {
crate::menu_level::set_anchor(ui.ctx(), 2, r.response.rect);
}
}
// imported LUT profile groups
let mut lut_groups: Vec<String> = app.session.lut_profiles.iter().map(|p| p.group.clone()).collect();
@@ -477,12 +489,17 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
for g in &lut_groups {
// a folder's name as it is, and its own widget id even when it matches a built-in group
let r = crate::widgets::menu_button(ui, g.as_str(), |ui| {
ui.set_min_width(170.0);
for p in app.session.lut_profiles.iter().filter(|p| &p.group == g) {
item(ui, "group", &p.id, &p.name, &mut pick);
}
crate::menu_level::level(ui, 2, Some(below), |ui| {
ui.set_min_width(170.0);
for p in app.session.lut_profiles.iter().filter(|p| &p.group == g) {
item(ui, "group", &p.id, &p.name, &mut pick);
}
});
});
register(ui.ctx(), format!("profileMenu:lutGroupMenu:{g}"), r.response.rect);
if r.inner.is_some() {
crate::menu_level::set_anchor(ui.ctx(), 2, r.response.rect);
}
}
ui.separator();
if let Some((name, _)) = app.session.profile_info(cur) {
+1
View File
@@ -162,6 +162,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
Flag::Reject if on => t.reject,
_ => Color32::TRANSPARENT,
};
let icon = if on && flag == Flag::Pick { Icon::FlagPickFilled } else { icon };
let resp = toggle(ui, id, on, tip, |p, r, c| paint(p, r, icon, if color == Color32::TRANSPARENT { c } else { color }));
if resp.clicked() {
filter(app, json!({"flag": if on { Value::Null } else { json!(id) }}));
+4 -3
View File
@@ -709,7 +709,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
}
x += photo.rating as f32 * 13.0 + 4.0;
match photo.flag {
Flag::Pick => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagPick, t.pick),
Flag::Pick => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagPickFilled, t.pick),
Flag::Reject => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagReject, t.reject),
Flag::None => {}
}
@@ -1010,7 +1010,7 @@ pub fn label_param(l: Option<ColorLabel>) -> String {
}
/// Stack badge at the cell's top-left: the photo count on a collapsed stack's top, `i/n` on the
/// members of an expanded stack. Clicking it expands/collapses the stack.
/// members of an expanded stack. Clicking or double-clicking it expands/collapses the stack.
pub fn stack_badge(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, sid: lightcraft_catalog::StackId, pos: usize, r: Rect, square: bool) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.catalog.stack(sid) else { return };
@@ -1034,7 +1034,8 @@ pub fn stack_badge(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, sid:
}
paint(p, Rect::from_min_size(br.min + vec2(6.0, 3.0), vec2(14.0, 14.0)), Icon::Stack, Color32::WHITE);
p.galley(pos2(br.min.x + 24.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE);
if resp.clicked() {
// a double-click (Lightroom's way) toggles once: its second click would undo the first
if resp.clicked() && !resp.double_clicked() {
let _ = app.run("stack.toggle", json!({"ids": [id.0]}));
}
}
+37 -7
View File
@@ -793,6 +793,8 @@ fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize {
struct LocalNow {
places: LocalPlaces,
current: Option<String>,
/// The places removed from Local, as (name, path), in the order they were hidden.
hidden: Vec<(String, String)>,
}
/// Folders on this computer to browse without adding (Lightroom's Local): Pictures, Desktop,
@@ -817,11 +819,14 @@ fn local_now(app: &mut LightcraftApp, ui: &egui::Ui) -> Option<LocalNow> {
}
let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder);
let current = browsing.as_ref().map(|b| b.path.clone());
// a hidden built-in place keeps its name (Home, not the user's folder name)
let name_of = |path: &str| builtin.iter().find(|(_, p)| same_folder(p, path)).map_or_else(|| folder_label(path), |(n, _)| n.clone());
let hidden = app.ui.hidden_locations.iter().map(|h| (name_of(h), h.clone())).collect();
let places = local_places(builtin, &app.ui.local_roots, current.as_deref(), app.ui.local_browse_root.as_deref(), &app.ui.hidden_locations);
if current.is_some() {
app.ui.local_browse_root = places.browse_root.clone();
}
Some(LocalNow { places, current })
Some(LocalNow { places, current, hidden })
}
/// Local's rows: each place as a folder tree, Browse Folder…, and the way back for hidden places.
@@ -846,18 +851,43 @@ fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &LocalNow) {
}
}
}
let hidden = app.ui.hidden_locations.len();
// the way back for hidden places: one comes back from the row itself; several are listed, so
// each comes back on its own, or all at once (issue #2114: the row used to bring every one back)
let hidden = now.hidden.len();
if hidden > 0 {
let label = crate::i18n::tr_format!("Show {hidden} hidden location{}", if hidden == 1 { "" } else { "s" }, hidden = hidden);
if row(app, ui, "local:restoreHidden", Icon::Folder, &label, None, false, 0.0)
.on_hover_text(crate::i18n::tr("Put the locations you removed from Local back (no files change)"))
.clicked()
{
let _ = app.run("local.restoreHidden", json!({}));
let resp = row(app, ui, "local:restoreHidden", Icon::Folder, &label, None, false, 0.0)
.on_hover_text(crate::i18n::tr("Put the locations you removed from Local back (no files change)"));
if let [(_, only)] = now.hidden.as_slice() {
if resp.clicked() {
let _ = app.run("local.restoreHidden", json!({"path": only}));
}
} else {
egui::Popup::menu(&resp).show(|ui| hidden_menu(app, ui, &now.hidden));
}
}
}
/// The list behind "Show N hidden locations": each place by name (its path on hover), then
/// Restore All.
fn hidden_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, hidden: &[(String, String)]) {
for (name, path) in hidden {
let r = ui.button(name).on_hover_text(path);
register(ui.ctx(), format!("local:restoreHidden:{path}"), r.rect);
if r.clicked() {
let _ = app.run("local.restoreHidden", json!({"path": path}));
ui.close();
}
}
ui.separator();
let r = ui.button(crate::i18n::tr("Restore All"));
register(ui.ctx(), "local:restoreHidden:all", r.rect);
if r.clicked() {
let _ = app.run("local.restoreHidden", json!({}));
ui.close();
}
}
/// Whether two paths name the same folder, however they are spelled (separators, trailing
/// slash, `.`/`..`, drive-letter case; see `lightcraft_catalog::query::folder_key`).
pub(crate) fn same_folder(a: &str, b: &str) -> bool {
+268 -69
View File
@@ -109,16 +109,50 @@ const GIVE_UP_FACTOR: f64 = 2.0;
/// The full-size drafts (after the first) a drag times before it may give up on full size.
const GIVE_UP_AFTER: usize = 3;
/// The most (photo, control) pairs [`DragScale`] remembers as too slow for full-size drafts.
const SLOW_DRAGS_KEPT: usize = 256;
/// The most (photo, control) pairs [`DragScale`] keeps a verdict on; past it, the one remembered
/// longest ago is forgotten.
const VERDICTS_KEPT: usize = 256;
/// A host frame time (ms) longer than this is not a texture upload but a window that was hidden,
/// occluded or stalled: it counts as unknown, like a frame time from no host at all.
const FRAME_MS_KNOWN: f64 = 1000.0;
/// A drag counts against its (photo, control) pair only when its full-size drafts took longer than
/// this (ms): a quarter over the budget, so a drag that only just misses it keeps full size.
const VERDICT_MS: f64 = DRAFT_BUDGET_MS * 1.25;
/// How long (s) a pair found slow drafts small before a drag of it tries full size again: the
/// machine may only have been busy. Each retry that is slow too doubles the wait, up to
/// [`RETRY_MAX_S`]. A retry costs at most a few slow drafts before the drag gives up on full size.
const RETRY_AFTER_S: f64 = 30.0;
/// The longest wait (s) before a slow pair tries full size again.
const RETRY_MAX_S: f64 = 240.0;
/// What [`DragScale`] remembers about the drags of one control on one photo.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Verdict {
/// One drag was over budget at full size: maybe a busy moment, so the next stays full size.
Suspect,
/// Two drags in a row were: drafts are small until `until` (the host's clock, s), then a drag
/// tries full size again; `wait` is how long this verdict holds (s).
Slow { until: f64, wait: f64 },
}
/// The size of a slider drag's whole-frame draft, chosen when the drag starts and kept until it
/// ends (a change in the middle would make the resolution jump), unless its full-size drafts are
/// far over budget. A drag renders at full size when the GPU draws the view and no earlier drag of
/// the same control on this photo went over budget at that size; otherwise at [`CPU_DRAFT_SCALE`]
/// (issue #330). Whether the GPU drew a render is not enough on its own: some views (a LUT
/// profile, refined masks, a photo past a GPU limit) fall back to the CPU per render, and a large
/// draft costs a texture upload too, which only the time a draft took to show tells.
/// far over budget. A drag renders at full size when the GPU draws the view and the same control
/// on this photo isn't known to be too slow at that size; otherwise at [`CPU_DRAFT_SCALE`] (issue
/// #330). Whether the GPU drew a render is not enough on its own: some views (a LUT profile,
/// refined masks, a photo past a GPU limit) fall back to the CPU per render, and a large draft
/// costs a texture upload too, which only the time a draft took to show tells.
///
/// A drag's time is wall-clock time, what the user waits for. So that a moment the machine was busy
/// (another app, a backup) can't make a slider draft small for the rest of the session, a pair is
/// slow only after two drags in a row over [`VERDICT_MS`], and only for a while: after
/// [`RETRY_AFTER_S`] a drag tries full size again, a fast one forgets the verdict and a slow one
/// doubles the wait. A verdict is for its photo only: the same control on another photo (another
/// size, other edits) is timed afresh.
#[derive(Clone, Debug, Default)]
pub struct DragScale {
/// The drag running now: (photo, control) and its scale.
@@ -130,26 +164,28 @@ pub struct DragScale {
/// The render time (ms) of a draft that reached the screen this frame: the time of the frame
/// that uploads it is only known on the next one.
pending: Option<f64>,
/// Drags whose full-size drafts went over budget.
slow: std::collections::HashSet<(lightcraft_catalog::PhotoId, String)>,
/// Pairs whose full-size drafts went over budget, the one remembered longest ago first.
verdicts: std::collections::VecDeque<((lightcraft_catalog::PhotoId, String), Verdict)>,
}
impl DragScale {
/// The draft scale for this frame, or `None` without a drag. `drag` is the photo and control
/// being dragged; `gpu` whether the GPU draws this view; `shown` the key of the whole-frame
/// render on screen (a render from before the drag is not one of its drafts).
pub fn scale(&mut self, drag: Option<(lightcraft_catalog::PhotoId, &str)>, gpu: bool, shown: Option<u64>) -> Option<f32> {
/// render on screen (a render from before the drag is not one of its drafts); `now` the host's
/// clock (s; egui's `input.time`).
pub fn scale(&mut self, drag: Option<(lightcraft_catalog::PhotoId, &str)>, gpu: bool, shown: Option<u64>, now: f64) -> Option<f32> {
let Some((photo, control)) = drag else {
self.finish();
self.finish(now);
return None;
};
match &self.current {
Some(((p, c), s)) if *p == photo && c == control => return Some(*s),
Some(_) => self.finish(),
Some(_) => self.finish(now),
None => {}
}
let key = (photo, control.to_string());
let s = if gpu && !self.slow.contains(&key) { 1.0 } else { CPU_DRAFT_SCALE };
let small = matches!(self.verdict(&key), Some(Verdict::Slow { until, .. }) if now < until);
let s = if gpu && !small { 1.0 } else { CPU_DRAFT_SCALE };
self.current = Some((key, s));
self.seen = shown;
Some(s)
@@ -167,7 +203,7 @@ impl DragScale {
return;
}
if let Some(render) = self.pending.take() {
let frame = last_frame_ms.filter(|ms| ms.is_finite() && *ms >= 0.0).unwrap_or(0.0);
let frame = last_frame_ms.filter(|ms| ms.is_finite() && (0.0..=FRAME_MS_KNOWN).contains(ms)).unwrap_or(0.0);
if self.samples.len() < 1024 {
self.samples.push(render + frame);
}
@@ -188,21 +224,58 @@ impl DragScale {
}
}
/// Remember drags of `control` on `photo` as too slow for full-size drafts (tests).
/// Remember drags of `control` on `photo` as too slow for full-size drafts, for good (tests).
#[cfg(test)]
pub fn remember_slow(&mut self, photo: lightcraft_catalog::PhotoId, control: &str) {
self.slow.insert((photo, control.to_string()));
self.set_verdict((photo, control.to_string()), Some(Verdict::Slow { until: f64::INFINITY, wait: RETRY_MAX_S }));
}
/// The drag ended: remember it if its full-size drafts were over budget.
fn finish(&mut self) {
if let Some((key, _)) = self.current.take()
&& median_after_first(&self.samples).is_some_and(|m| m > DRAFT_BUDGET_MS)
{
if self.slow.len() >= SLOW_DRAGS_KEPT {
self.slow.clear();
fn verdict(&self, key: &(lightcraft_catalog::PhotoId, String)) -> Option<Verdict> {
self.verdicts.iter().find(|(k, _)| k == key).map(|(_, v)| *v)
}
/// Set or (`None`) forget a pair's verdict. A new pair forgets the one remembered longest ago
/// when the list is full (not all of them: each forgotten pair costs one more slow drag).
fn set_verdict(&mut self, key: (lightcraft_catalog::PhotoId, String), verdict: Option<Verdict>) {
let at = self.verdicts.iter().position(|(k, _)| *k == key);
match (at, verdict) {
(Some(i), Some(v)) => {
if let Some(entry) = self.verdicts.get_mut(i) {
entry.1 = v;
}
}
self.slow.insert(key);
(Some(i), None) => {
self.verdicts.remove(i);
}
(None, Some(v)) => {
while self.verdicts.len() >= VERDICTS_KEPT {
self.verdicts.pop_front();
}
self.verdicts.push_back((key, v));
}
(None, None) => {}
}
}
/// The drag ended. A drag timed at full size updates its pair's verdict: over [`VERDICT_MS`]
/// a first time makes it suspect, a second slow for [`RETRY_AFTER_S`], and a slow retry
/// doubles the wait; within it, the verdict is forgotten. A drag never timed at full size (it
/// drafted small, or was too short) changes nothing.
fn finish(&mut self, now: f64) {
if let Some((key, _)) = self.current.take()
&& let Some(median) = median_after_first(&self.samples)
{
let verdict = if median > VERDICT_MS {
let wait = match self.verdict(&key) {
None => None,
Some(Verdict::Suspect) => Some(RETRY_AFTER_S),
Some(Verdict::Slow { wait, .. }) => Some((wait * 2.0).min(RETRY_MAX_S)),
};
Some(wait.map_or(Verdict::Suspect, |wait| Verdict::Slow { until: now + wait, wait }))
} else {
None
};
self.set_verdict(key, verdict);
}
self.samples.clear();
self.seen = None;
@@ -440,10 +513,10 @@ mod tests {
fn a_drag_shrinks_the_draft_only_on_the_cpu() {
use lightcraft_catalog::PhotoId;
let mut gpu = DragScale::default();
assert_eq!(gpu.scale(None, true, None), None, "no drag, no draft");
assert_eq!(gpu.scale(Some((PhotoId(1), "Exposure")), true, None), Some(1.0));
assert_eq!(gpu.scale(None, true, None, 0.0), None, "no drag, no draft");
assert_eq!(gpu.scale(Some((PhotoId(1), "Exposure")), true, None, 0.0), Some(1.0));
let mut cpu = DragScale::default();
assert_eq!(cpu.scale(Some((PhotoId(1), "Exposure")), false, None), Some(CPU_DRAFT_SCALE));
assert_eq!(cpu.scale(Some((PhotoId(1), "Exposure")), false, None, 0.0), Some(CPU_DRAFT_SCALE));
}
// Given a drag that started before the GPU was ready, it keeps its size until it ends: the
@@ -453,33 +526,120 @@ mod tests {
use lightcraft_catalog::PhotoId;
let mut d = DragScale::default();
let drag = Some((PhotoId(1), "Exposure"));
assert_eq!(d.scale(drag, false, None), Some(CPU_DRAFT_SCALE));
assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE), "the GPU came up mid-drag");
assert_eq!(d.scale(None, true, None), None);
assert_eq!(d.scale(drag, true, None), Some(1.0), "the next drag gets full size");
assert_eq!(d.scale(drag, false, None, 0.0), Some(CPU_DRAFT_SCALE));
assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "the GPU came up mid-drag");
assert_eq!(d.scale(None, true, None, 0.0), None);
assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0), "the next drag gets full size");
// and the other way: the GPU lost mid-drag keeps the drag at full size until it ends
assert_eq!(d.scale(drag, false, None), Some(1.0));
assert_eq!(d.scale(drag, false, None, 0.0), Some(1.0));
}
// Given full-size drafts over the slider budget (a LUT profile or refined mask the CPU renders,
// a photo past a GPU limit, a slow GPU), the next drag of that control on that photo drafts
// small; other controls and photos keep full size
/// One whole drag of `drag` at full size whose drafts all take `ms` to show, ending at `now`.
fn timed_drag(d: &mut DragScale, drag: Option<(lightcraft_catalog::PhotoId, &str)>, ms: f64, now: f64) -> Option<f32> {
let scale = d.scale(drag, true, None, now);
for key in 1..=6 {
d.observe(key, ms, None);
}
d.scale(None, true, None, now);
scale
}
// Given full-size drafts over the slider budget twice in a row (a LUT profile or refined mask
// the CPU renders, a photo past a GPU limit, a slow GPU), the next drag of that control on
// that photo drafts small; once could be a busy moment. Other controls and photos keep full size
#[test]
fn a_drag_over_budget_drafts_small_the_next_time() {
fn a_drag_over_budget_twice_drafts_small_the_next_time() {
use lightcraft_catalog::PhotoId;
let mut d = DragScale::default();
let exposure = Some((PhotoId(1), "Exposure"));
assert_eq!(d.scale(exposure, true, Some(7)), Some(1.0));
assert_eq!(d.scale(exposure, true, Some(7), 0.0), Some(1.0));
d.observe(7, 400.0, None); // the render from before the drag: not a draft of it
for (key, ms) in [(8, 300.0), (9, 20.0), (10, 25.0), (11, 9.0)] {
for (key, ms) in [(8, 300.0), (9, 30.0), (10, 25.0), (11, 9.0)] {
d.observe(key, ms, None);
d.observe(key, ms, None); // a render is timed once, however many frames show it
}
assert_eq!(d.scale(exposure, true, None), Some(1.0), "over budget, but not far enough to give up mid-drag");
assert_eq!(d.scale(None, true, None), None);
assert_eq!(d.scale(exposure, true, None), Some(CPU_DRAFT_SCALE));
assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None), Some(1.0));
assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None), Some(1.0));
assert_eq!(d.scale(exposure, true, None, 0.0), Some(1.0), "over budget, but not far enough to give up mid-drag");
assert_eq!(d.scale(None, true, None, 0.0), None);
assert_eq!(timed_drag(&mut d, exposure, 25.0, 1.0), Some(1.0), "one slow drag: maybe the machine was busy");
assert_eq!(d.scale(exposure, true, None, 2.0), Some(CPU_DRAFT_SCALE));
d.scale(None, true, None, 2.0);
assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None, 2.0), Some(1.0));
assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 2.0), Some(1.0));
}
// Given a pair found slow, it drafts small only for a while: then a drag tries full size
// again; a slow retry doubles the wait (up to its cap), a fast one forgets the verdict
#[test]
fn a_slow_verdict_expires_and_is_retried() {
use lightcraft_catalog::PhotoId;
let mut d = DragScale::default();
let drag = Some((PhotoId(1), "Exposure"));
timed_drag(&mut d, drag, 25.0, 0.0);
timed_drag(&mut d, drag, 25.0, 0.0);
assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S - 1.0), Some(CPU_DRAFT_SCALE), "slow until the wait is over");
assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S), Some(1.0), "then a drag tries full size: still slow");
let mut at = RETRY_AFTER_S;
let mut wait = RETRY_AFTER_S * 2.0;
for _ in 0..6 {
assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait - 1.0), Some(CPU_DRAFT_SCALE), "the wait doubled to {wait} s");
assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait), Some(1.0), "a retry after {wait} s, slow again");
at += wait;
wait = (wait * 2.0).min(RETRY_MAX_S);
}
assert_eq!(wait, RETRY_MAX_S, "the wait stops growing");
assert_eq!(timed_drag(&mut d, drag, 8.0, at + wait), Some(1.0), "a retry, fast this time");
assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 1.0), Some(1.0), "forgotten: one slow drag is a suspicion again");
assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 2.0), Some(1.0), "suspect: still full size");
assert_eq!(d.scale(drag, true, None, at + wait + 3.0), Some(CPU_DRAFT_SCALE), "slow from the start again: wait 30 s");
}
// Given a drag only just over budget, it doesn't count against the pair (it would flip between
// the two sizes); one clearly over does
#[test]
fn only_a_drag_clearly_over_budget_counts() {
use lightcraft_catalog::PhotoId;
let mut d = DragScale::default();
let drag = Some((PhotoId(1), "Exposure"));
for t in 0..4 {
timed_drag(&mut d, drag, 18.0, f64::from(t));
}
assert_eq!(d.scale(drag, true, None, 4.0), Some(1.0), "18 ms drafts: just over the 16 ms budget");
d.scale(None, true, None, 4.0);
timed_drag(&mut d, drag, VERDICT_MS + 5.0, 5.0);
timed_drag(&mut d, drag, VERDICT_MS + 5.0, 6.0);
assert_eq!(d.scale(drag, true, None, 7.0), Some(CPU_DRAFT_SCALE));
}
// Given one slow drag while the machine was busy, then fast ones, the slider keeps full size
#[test]
fn a_busy_moment_doesnt_make_a_slider_draft_small() {
use lightcraft_catalog::PhotoId;
let mut d = DragScale::default();
let drag = Some((PhotoId(1), "Exposure"));
timed_drag(&mut d, drag, 40.0, 0.0);
for t in 1..=3 {
assert_eq!(timed_drag(&mut d, drag, 8.0, f64::from(t)), Some(1.0));
}
timed_drag(&mut d, drag, 40.0, 4.0);
assert_eq!(d.scale(drag, true, None, 5.0), Some(1.0), "the fast drags forgot the first slow one");
}
// Given a hostile host clock (NaN, infinite, going backwards), a drag never panics; a clock it
// can't read tries full size
#[test]
fn a_hostile_clock_never_panics() {
use lightcraft_catalog::PhotoId;
let mut d = DragScale::default();
let drag = Some((PhotoId(1), "Exposure"));
timed_drag(&mut d, drag, 25.0, 100.0);
timed_drag(&mut d, drag, 25.0, 100.0);
assert_eq!(d.scale(drag, true, None, 50.0), Some(CPU_DRAFT_SCALE), "the clock went backwards: still slow");
d.scale(None, true, None, 50.0);
assert_eq!(d.scale(drag, true, None, f64::NAN), Some(1.0));
d.scale(None, true, None, f64::NAN);
for now in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN, -1.0] {
timed_drag(&mut d, drag, 25.0, now);
}
}
// Given full-size drafts within budget (the first one, rebuilding the stage caches, may be
@@ -489,18 +649,18 @@ mod tests {
use lightcraft_catalog::PhotoId;
let mut d = DragScale::default();
let drag = Some((PhotoId(1), "Exposure"));
d.scale(drag, true, None);
d.scale(drag, true, None, 0.0);
for (key, ms) in [(1, 120.0), (2, 4.0), (3, 6.0), (4, 30.0), (5, 5.0)] {
d.observe(key, ms, None);
}
d.scale(None, true, None);
assert_eq!(d.scale(drag, true, None), Some(1.0));
d.scale(None, true, None, 0.0);
assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0));
// hostile times are ignored, never a panic
for (key, ms) in [(6, f64::NAN), (7, f64::INFINITY), (8, -1.0)] {
d.observe(key, ms, None);
}
d.scale(None, true, None);
assert_eq!(d.scale(drag, true, None), Some(1.0));
d.scale(None, true, None, 0.0);
assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0));
}
// Given renders within budget whose frames (the texture upload) take it over, the drag counts
@@ -510,50 +670,89 @@ mod tests {
use lightcraft_catalog::PhotoId;
let mut d = DragScale::default();
let drag = Some((PhotoId(1), "Exposure"));
d.scale(drag, true, None);
for key in 1..=5 {
d.observe(key, 8.0, Some(12.0)); // the previous frame, which uploaded the last draft
for _ in 0..2 {
d.scale(drag, true, None, 0.0);
for key in 1..=5 {
d.observe(key, 8.0, Some(14.0)); // the previous frame, which uploaded the last draft
}
d.observe(5, 8.0, Some(14.0));
d.scale(None, true, None, 0.0);
}
d.observe(5, 8.0, Some(12.0));
d.scale(None, true, None);
assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE), "8 ms renders + 12 ms frames: over 16 ms");
assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "8 ms renders + 14 ms frames: over budget");
// the same renders without the host's frame times (headless) stay within budget
let mut headless = DragScale::default();
headless.scale(drag, true, None);
for key in 1..=6 {
headless.observe(key, 8.0, None);
headless.scale(drag, true, None, 0.0);
for _ in 0..2 {
timed_drag(&mut headless, drag, 8.0, 0.0);
}
headless.scale(None, true, None);
assert_eq!(headless.scale(drag, true, None), Some(1.0));
assert_eq!(headless.scale(drag, true, None, 0.0), Some(1.0));
// a hostile frame time counts as nothing, never a panic
let mut hostile = DragScale::default();
hostile.scale(drag, true, None);
hostile.scale(drag, true, None, 0.0);
for (key, frame) in [(1, f64::NAN), (2, f64::INFINITY), (3, -1.0), (4, f64::NAN)] {
hostile.observe(key, 8.0, Some(frame));
}
hostile.scale(None, true, None);
assert_eq!(hostile.scale(drag, true, None), Some(1.0));
hostile.scale(None, true, None, 0.0);
assert_eq!(hostile.scale(drag, true, None, 0.0), Some(1.0));
}
// Given more slow drags than are remembered, only the one remembered longest ago is forgotten:
// clearing them all would bring back a slow full-size drag for every pair
#[test]
fn a_full_slow_set_forgets_only_the_oldest_drag() {
use lightcraft_catalog::PhotoId;
let mut d = DragScale::default();
for photo in 0..=VERDICTS_KEPT as u64 {
d.remember_slow(PhotoId(photo), "Exposure");
}
assert_eq!(d.scale(Some((PhotoId(0), "Exposure")), true, None, 0.0), Some(1.0), "the oldest is forgotten");
d.scale(None, true, None, 0.0);
for photo in [1, VERDICTS_KEPT as u64] {
assert_eq!(d.scale(Some((PhotoId(photo), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE), "photo {photo}");
d.scale(None, true, None, 0.0);
}
d.remember_slow(PhotoId(1), "Exposure"); // already remembered: nothing is forgotten
assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE));
}
// Given draft frames that took seconds (the window hidden or occluded mid-drag), they count as
// unknown: fast renders don't mark the drag slow because the window wasn't drawing. Two such
// drags, since one slow drag only makes the pair suspect
#[test]
fn a_hidden_windows_frame_times_dont_mark_a_drag_slow() {
use lightcraft_catalog::PhotoId;
let mut d = DragScale::default();
let drag = Some((PhotoId(1), "Exposure"));
for t in 0..2u32 {
assert_eq!(d.scale(drag, true, None, f64::from(t)), Some(1.0), "drag {}", t + 1);
for key in 1..=6 {
d.observe(u64::from(t) * 10 + key, 6.0, Some(4000.0));
}
d.scale(None, true, None, f64::from(t));
}
assert_eq!(d.scale(drag, true, None, 2.0), Some(1.0), "after two hidden-window drags: still full size");
}
// Given full-size drafts far over budget (a refined mask the CPU renders at 190 ms a step),
// the drag drops to the small draft after a few of them instead of at its end, and the next
// drag starts small
// the drag drops to the small draft after a few of them instead of at its end; after a second
// such drag, the next starts small
#[test]
fn a_drag_far_over_budget_drafts_small_before_it_ends() {
use lightcraft_catalog::PhotoId;
let mut d = DragScale::default();
let drag = Some((PhotoId(1), "Exposure"));
assert_eq!(d.scale(drag, true, None), Some(1.0));
assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0));
let mut scales = Vec::new();
for key in 1..=8 {
d.observe(key, 190.0, None);
scales.push(d.scale(drag, true, None));
scales.push(d.scale(drag, true, None, 0.0));
}
// drafts 1–4 are timed one frame late; the median of 2–4 decides on the fifth frame
assert_eq!(scales[..4], [Some(1.0); 4], "{scales:?}");
assert_eq!(scales[4..], [Some(CPU_DRAFT_SCALE); 4], "{scales:?}");
d.scale(None, true, None);
assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE));
d.scale(None, true, None, 0.0);
assert_eq!(timed_drag(&mut d, drag, 190.0, 1.0), Some(1.0), "one such drag: maybe a busy moment");
assert_eq!(d.scale(drag, true, None, 2.0), Some(CPU_DRAFT_SCALE));
}
// Given a full-size drag zoomed in (a window covers the view), the hidden whole-frame draft is
+2 -1
View File
@@ -699,7 +699,8 @@ pub struct UiState {
/// Mirroring of the triangle / spiral crop guides (0..4).
pub crop_overlay_orient: u8,
pub crop_overlay: CropOverlay,
/// Last explicitly chosen aspect, retained across photos and app launches.
/// Last explicitly chosen aspect, retained across photos and app launches; `None` (nothing
/// chosen yet) crops locked to the photo's own shape, as Lightroom starts (#991).
pub crop_default_aspect: Option<serde_json::Value>,
pub show_filenames: bool,
/// What the square grid's caption shows: `filename`, `exposure` (shutter · aperture · ISO ·
+93
View File
@@ -0,0 +1,93 @@
//! Headless tests of the ⌥-drag previews (issue #937): holding ⌥ (Option) while dragging Blacks,
//! Whites, Exposure or a Sharpening / Noise Reduction slider shows a diagnostic view in the loupe.
use std::time::Duration;
use egui::{Event, Modifiers, PointerButton};
use lightcraft_pipeline::Overlay;
use serde_json::json;
use crate::headless::Headless;
use crate::panels::detail::{SLIDER_PREVIEWS, slider_preview};
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
fn detail() -> Headless {
let services = Services { png: None, ..Default::default() };
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
let mut h = Headless::new(app, [1400.0, 1400.0], 1.0);
let r = h.request("ui.set", json!({"view": "detail", "right": "edit"}), T);
assert_eq!(r["ok"], true, "{r}");
if !h.app.ui.section_open("light") {
h.app.ui.toggle_section("light");
}
h.app.renderer.keep_pixels = true;
h.settle(SETTLE);
h
}
/// Share of the loupe render's pixels whose channels are all 0 or 255 (a clipping view).
fn binary_share(h: &Headless) -> f64 {
let t = h.app.renderer.textures.get(&crate::render::Slot::Main).expect("loupe render");
let px = &t.pixels.as_ref().expect("pixels kept").pixels;
let binary = px.iter().filter(|c| [c.r(), c.g(), c.b()].iter().all(|v| *v == 0 || *v == 255)).count();
binary as f64 / px.len().max(1) as f64
}
#[test]
fn alt_during_a_blacks_drag_shows_shadow_clipping_until_released() {
let mut h = detail();
assert!(binary_share(&h) < 0.5, "the photo itself is no clipping view");
let track = h.app.widgets.iter().find(|(w, _)| w == "slider:light.blacks").map(|(_, r)| *r).expect("Blacks slider on screen");
let from = track.center();
let before = h.app.session.develop_of(h.app.session.active().expect("photo")).expect("develop").light.blacks;
// a plain drag to the left, button held: the photo as usual
h.app.synthetic.push(Event::PointerMoved(from));
h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
for i in 1..=8 {
h.app.synthetic.push(Event::PointerMoved(from - egui::vec2(6.0 * i as f32, 0.0)));
}
h.settle(SETTLE);
assert_eq!(h.app.ui.dragging_control.as_deref(), Some("light.blacks"));
assert!(binary_share(&h) < 0.5, "no preview without ⌥");
// ⌥ pressed mid-drag: the shadow clipping view
h.app.synthetic.push(Event::ModifiersChanged(Modifiers::ALT));
h.settle(SETTLE);
assert!(binary_share(&h) > 0.99, "the clipping view: {}", binary_share(&h));
// ⌥ released: the photo again, the drag still on
h.app.synthetic.push(Event::ModifiersChanged(Modifiers::NONE));
h.settle(SETTLE);
assert!(binary_share(&h) < 0.5, "the photo is back");
h.app.synthetic.push(Event::PointerButton {
pos: from - egui::vec2(48.0, 0.0),
button: PointerButton::Primary,
pressed: false,
modifiers: Modifiers::NONE,
});
h.settle(SETTLE);
assert_eq!(h.app.ui.dragging_control, None);
let after = h.app.session.develop_of(h.app.session.active().expect("photo")).expect("develop").light.blacks;
assert!(after < before, "the drag itself still moved Blacks: {before} → {after}");
}
#[test]
fn each_slider_has_its_preview() {
assert_eq!(slider_preview("light.blacks"), Some(Overlay::ShadowClip));
for c in ["light.whites", "light.exposure"] {
assert_eq!(slider_preview(c), Some(Overlay::HighlightClip), "{c}");
}
assert_eq!(slider_preview("detail.sharpenMasking"), Some(Overlay::SharpenMask));
for c in ["detail.sharpenAmount", "detail.sharpenRadius", "detail.sharpenDetail", "detail.nrLuminance", "detail.nrDetail", "detail.nrContrast"] {
assert_eq!(slider_preview(c), Some(Overlay::Grey), "{c}");
}
for c in ["light.contrast", "color.saturation", "detail.nrColor"] {
assert_eq!(slider_preview(c), None, "{c}");
}
// every mapped id is a real control, once (a rename would silently drop its preview)
for (i, (c, _)) in SLIDER_PREVIEWS.iter().enumerate() {
assert!(lightcraft_develop::controls::find(c).is_some(), "{c}");
assert!(!SLIDER_PREVIEWS[..i].iter().any(|(d, _)| d == c), "{c} twice");
}
}
+45
View File
@@ -172,3 +172,48 @@ fn an_unedited_raws_thumbnail_skips_the_sensor_decode() {
assert!(!skips_the_real_render(false, true));
assert!(!skips_the_real_render(false, false));
}
/// Press and release the primary button at `at`, one frame apart from the next click (as a real
/// double-click comes in: one click per frame).
fn click_at(h: &mut Headless, at: egui::Pos2) {
h.events.push(egui::Event::PointerMoved(at));
h.events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
h.step();
h.events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
h.step();
}
/// Double-clicking a stack badge expands or collapses the stack, as in Lightroom (issue #2233): its
/// second click used to undo the first, and the stack stayed as it was. A single click still toggles it.
#[test]
fn double_clicking_a_stack_badge_toggles_the_stack_once() {
let mut h = grid(ViewMode::PhotoGrid);
let top = PhotoId(2001);
let sid = StackId(21);
let collapsed = |h: &Headless| h.app.session.catalog.stack(sid).unwrap().collapsed;
select(&mut h, top);
let r = h.request("engine.execute", json!({"command": "stack.toggle", "params": {"ids": [top.0]}}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert!(collapsed(&h));
for want in [false, true] {
let badge = widget(&h, &format!("stack:{}", top.0)).expect("stack badge drawn").center();
click_at(&mut h, badge);
// the first click re-lays the grid; the second must still land on this badge, or the test
// would pass without the fix (a missed second click toggles nothing)
let moved = widget(&h, &format!("stack:{}", top.0)).expect("stack badge drawn");
assert!(moved.contains(badge), "the badge moved from under the pointer: {moved:?}");
click_at(&mut h, badge);
h.settle(SETTLE);
assert_eq!(collapsed(&h), want, "a double-click toggles the stack once");
assert_eq!(h.app.ui.view, ViewMode::PhotoGrid, "a double-click on the badge doesn't open the photo");
// the next double-click is apart from this one
for _ in 0..60 {
h.step();
}
}
let badge = widget(&h, &format!("stack:{}", top.0)).unwrap().center();
click_at(&mut h, badge);
h.settle(SETTLE);
assert!(!collapsed(&h), "a single click toggles it");
}
+55 -6
View File
@@ -159,23 +159,23 @@ fn mask_adjustments_hide_overlay_without_disabling_the_mask() {
let d = develop(&h);
assert!(d.masks[0].adjust.exposure > 0.0);
assert!(d.masks[0].visible, "hiding the overlay must not disable the mask");
assert_eq!(crate::panels::detail::view_overlay(&h.app, &d), Overlay::None);
assert_eq!(crate::panels::detail::view_overlay(&h.app, &d, false), Overlay::None);
let mask = d.masks[0].clone();
let r = h.request("ui.key", json!({"key": "o"}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(h.app.ui.mask_overlay, "O can restore the overlay");
assert_eq!(develop(&h).masks[0], mask, "toggling the overlay leaves the mask and its effect unchanged");
assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::Mask { .. }));
assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h), false), Overlay::Mask { .. }));
// Ctrl+Shift+O / Cmd+Shift+O shows every visible mask's region at once (the new command), ahead of the selected
// mask overlay so it is not shadowed by the always-on single-mask overlay.
exec(&mut h, "view.maskOverlayAll", json!({}));
assert!(h.app.ui.mask_overlay_all, "the all-masks overlay command turns it on");
assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::AllMasks { .. }));
assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h), false), Overlay::AllMasks { .. }));
exec(&mut h, "view.maskOverlayAll", json!({}));
assert!(!h.app.ui.mask_overlay_all, "the all-masks overlay command toggles it off");
assert!(!matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::AllMasks { .. }));
assert!(!matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h), false), Overlay::AllMasks { .. }));
// Failed edits and global adjustments must not hide the mask overlay.
assert!(h.app.run("mask.adjust", json!({"id": 999, "values": {"exposure": 1.0}})).is_err());
@@ -274,12 +274,12 @@ fn mask_overlay_keys_and_pins() {
assert_eq!(h.app.session.active_mask, Some(2));
// the loupe asks the renderer for the selected mask's overlay
let d = develop(&h);
let o = crate::panels::detail::view_overlay(&h.app, &d);
let o = crate::panels::detail::view_overlay(&h.app, &d, false);
assert_eq!(o, Overlay::Mask { id: 2, view: MaskView::Color, color: [230, 30, 40], opacity: 50 });
// O toggles it, Shift+O cycles the colour (and leaves the crop overlay alone)
h.request("ui.key", json!({"key": "o"}), T);
assert!(!h.app.ui.mask_overlay);
assert_eq!(crate::panels::detail::view_overlay(&h.app, &d), Overlay::None);
assert_eq!(crate::panels::detail::view_overlay(&h.app, &d, false), Overlay::None);
h.request("ui.key", json!({"key": "o"}), T);
let crop = h.app.ui.crop_overlay;
let colour = h.app.ui.mask_overlay_color;
@@ -539,6 +539,55 @@ fn crop_drag_activates_the_remembered_ratio_and_undo_restores_the_photo() {
assert_eq!(develop(&h), before);
}
/// Issue #991: with no aspect ever chosen, a crop starts locked to the photo's own shape (Lightroom's
/// default), so a handle drag keeps the proportions; after the Lock is switched off once, the next
/// photo's crop starts free again.
#[test]
fn a_fresh_crop_starts_locked_to_original_until_the_lock_is_switched_off() {
let mut h = detail("panel.crop");
h.app.ui.crop_default_aspect = None;
h.settle(SETTLE);
let before = develop(&h);
assert!(before.crop.aspect.is_none());
let p = h.app.session.catalog.photo(h.app.session.active().unwrap()).unwrap().clone();
let own = (p.width * 100, p.height * 100);
assert_eq!(h.app.crop_tool_aspect(), Some(own), "the Lock shows engaged on an untouched photo");
let drag = json!({"events": [
{"kind": "down", "x": 0.995, "y": 0.995},
{"kind": "drag", "x": 0.85, "y": 0.85},
{"kind": "drag", "x": 0.75, "y": 0.75},
{"kind": "up", "x": 0.75, "y": 0.75}
]});
let r = h.request("ui.pointer", drag.clone(), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert_eq!(develop(&h).crop.aspect, Some(own), "the drag kept the photo's proportions");
assert_eq!(h.app.ui.crop_default_aspect, None, "nothing was chosen explicitly");
exec(&mut h, "edit.undo", json!({}));
assert_eq!(develop(&h), before);
// Lock off once: remembered as free, and from then on the same drag crops without a ratio
let mut h = detail("panel.crop");
h.app.ui.crop_default_aspect = None;
h.settle(SETTLE);
h.app.run("crop.aspect", json!({"aspect": "toggle"})).unwrap();
h.settle(SETTLE);
assert_eq!(h.app.ui.crop_default_aspect, Some(json!("free")));
assert_eq!(h.app.crop_tool_aspect(), None);
let before = develop(&h);
// a rotating drag outside the box (any crop drag starts cropping); the release on its own
for events in [
json!([{"kind": "down", "x": -0.05, "y": 0.2}, {"kind": "drag", "x": -0.05, "y": 0.25}, {"kind": "drag", "x": -0.05, "y": 0.3}]),
json!([{"kind": "up", "x": -0.05, "y": 0.3}]),
] {
let r = h.request("ui.pointer", json!({ "events": events }), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
}
assert_ne!(develop(&h).crop, before.crop, "the drag cropped");
assert_eq!(develop(&h).crop.aspect, None, "and left the ratio free");
let _ = drag;
}
/// A remembered ratio the crop command refuses (a hand-edited settings file) is forgotten, and the
/// drag still crops: it must not block every crop of an untouched photo.
#[test]
+5 -2
View File
@@ -212,7 +212,10 @@ mod in_the_loupe {
h.settle(SETTLE);
let id = h.app.session.active().expect("a photo");
let d = h.app.session.develop_of(id).expect("settings");
assert!(matches!(crate::panels::detail::view_overlay(&h.app, &d), lightcraft_pipeline::Overlay::Mask { .. }), "the mask overlay is shown");
assert!(
matches!(crate::panels::detail::view_overlay(&h.app, &d, false), lightcraft_pipeline::Overlay::Mask { .. }),
"the mask overlay is shown"
);
assert_eq!((h.app.ui.zoom, h.app.ui.pan), (zoom, pan), "showing the overlay keeps zoom and pan");
let main = rendered_long_edge(&h);
assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}");
@@ -250,7 +253,7 @@ mod in_the_loupe {
let id = h.app.session.active().expect("a photo");
let d = h.app.session.develop_of(id).expect("settings");
assert!(
matches!(crate::panels::detail::view_overlay(&h.app, &d), lightcraft_pipeline::Overlay::AllMasks { .. }),
matches!(crate::panels::detail::view_overlay(&h.app, &d, false), lightcraft_pipeline::Overlay::AllMasks { .. }),
"the all-masks overlay is shown"
);
let main = rendered_long_edge(&h);
+52
View File
@@ -0,0 +1,52 @@
//! #2059: the profile dropdown and its submenus stay inside the window. A folder of many imported
//! profiles used to make its submenu run past the window's bottom edge, and the profiles below it
//! could not be reached.
use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(5);
const SETTLE: Duration = Duration::from_secs(10);
const FOLDER: &str = "Imported";
fn widget(h: &Headless, id: &str) -> Option<egui::Rect> {
h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r)
}
fn go(h: &mut Headless, method: &str, id: &str) {
let r = h.request(method, json!({"id": id}), T);
assert_eq!(r["ok"], true, "{method} {id}: {r}");
h.settle(SETTLE);
}
#[test]
fn a_folder_of_many_imported_profiles_scrolls_inside_the_window() {
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
for i in 0..80 {
app.session.lut_profiles.push(lightcraft_engine::cmd::lut_profiles::LutProfile {
id: format!("lut:imported-{i}"),
name: format!("Imported {i}"),
group: FOLDER.into(),
file: String::new(),
});
}
let mut h = Headless::new(app, [1200.0, 900.0], 1.0);
let r = h.request("ui.set", json!({"view": "detail"}), T);
assert_eq!(r["ok"], true, "{r}");
h.app.ui.right = crate::state::RightPanel::Edit;
h.settle(SETTLE);
go(&mut h, "ui.clickWidget", "dropdown:profile");
let menu = widget(&h, "menu-level:1").expect("the profile menu is open");
assert!(menu.bottom() <= h.size.y + 0.5, "the profile menu {menu:?} runs past the window's bottom {}", h.size.y);
go(&mut h, "ui.hoverWidget", &format!("profileMenu:lutGroupMenu:{FOLDER}"));
let sub = widget(&h, "menu-level:2").expect("the imported folder's submenu is open");
assert!(sub.bottom() <= h.size.y + 0.5, "the submenu {sub:?} runs past the window's bottom {}", h.size.y);
// Its 80 rows don't all fit: some are scrolled out of view rather than drawn off screen.
let rows: Vec<egui::Rect> = (0..80).filter_map(|i| widget(&h, &format!("profileMenu:group:lut:imported-{i}"))).collect();
assert!(!rows.is_empty(), "no imported profile rows drawn");
assert!(rows.iter().any(|r| !sub.contains_rect(*r)), "every row fits a 900 pt window");
}
File diff suppressed because one or more lines are too long
+25
View File
@@ -1,5 +1,11 @@
# What's new in LightCraft
## 2026-10-19
### Hidden Local locations come back one at a time
- With several locations removed from Local, "Show N hidden locations" now lists them, so each comes back on its own
(Restore All is there too). The row used to bring every hidden location back at once (issue #2114).
## 2026-10-18
### Fill Location from GPS: no more neighbouring town in City
@@ -8,6 +14,25 @@
and `metadata.reverseGeocode` takes `maxDistance` (metres): past it, City stays empty (State and Country still fill)
and the reply names the town it found as `nearestCity`.
### A preset that couldn't be saved is no longer lost silently
- When presets.json can't be written (a full disk, or the browser's storage quota), the new or changed preset now
counts as unsaved, like other changes: the top bar warns, saving is retried, and quitting asks first (issue #2266).
Before, the failure was only logged, and the preset could be lost without any warning.
### Portrait crop ratios stay portrait
- A crop ratio no longer flips to the photo's orientation (issue #2253): after X turns the crop portrait, picking
another ratio (4 × 5, 2 × 3…) keeps it portrait, as in Lightroom, and a Custom ratio taller than wide gives a
portrait crop.
## 2026-10-15
### Constrain Crop tightens the crop you see
- Geometry ▸ Constrain Crop now changes the crop itself, as in Lightroom (issue #1927). Ticking it, or moving a
Geometry slider, Upright or a lens correction while it is ticked, shrinks the crop about its centre until the blank
corners left by the correction are gone, and the Crop tool shows the tighter crop. Before, it only applied when the
photo was shown outside the Crop tool, so in the Crop panel, where the checkbox is, nothing seemed to change. A crop
you draw by hand is kept as drawn, and unticking keeps the tighter crop.
## 2026-10-14
### Model downloads time out on a name that doesn't resolve