mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
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:
@@ -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)
|
||||
|
||||
@@ -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(())
|
||||
})
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" }});
|
||||
|
||||
@@ -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),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) }}));
|
||||
|
||||
@@ -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]}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 ·
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
+21
-17
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user