From 96c3190300a4e31f7ccf612250ee9e859b89c1cf Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 26 Oct 2026 20:09:47 -0400 Subject: [PATCH 01/39] chore(clippy): enforce workspace lint rules --- Cargo.toml | 4 +++- clippy.toml | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 42d4e5209..cd85d80f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -122,9 +122,11 @@ web-sys = "0.3" unsafe_code = "deny" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" -too_many_arguments = "allow" needless_range_loop = "allow" type_complexity = "allow" excessive_precision = "allow" diff --git a/clippy.toml b/clippy.toml index 287e14edc..3522a15b6 100644 --- a/clippy.toml +++ b/clippy.toml @@ -5,3 +5,4 @@ allow-indexing-slicing-in-tests = true disallowed-types = [ { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, ] +cognitive-complexity-threshold = 30 From c7ef259f36d0f06fdfc9fa7af69393b46825520d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 02:54:53 +0000 Subject: [PATCH 02/39] refactor(heif): isolate SPS VUI parsing --- crates/heif/src/vui.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/heif/src/vui.rs b/crates/heif/src/vui.rs index 226604303..6bc629fa5 100644 --- a/crates/heif/src/vui.rs +++ b/crates/heif/src/vui.rs @@ -201,6 +201,11 @@ fn st_ref_pic_set(r: &mut Bits, idx: usize, num_delta_pocs: &[u32]) -> Option Option { let r = &mut Bits { data: rbsp, pos: 0 }; let (_, _, max_sub_layers_minus1) = sps_size(r)?; + parse_sps_parameters(r, max_sub_layers_minus1)?; + parse_vui(r) +} + +fn parse_sps_parameters(r: &mut Bits<'_>, max_sub_layers_minus1: u32) -> Option<()> { if r.flag()? { for _ in 0..4 { r.ue()?; // conf_win_*_offset @@ -253,6 +258,10 @@ fn parse_sps(rbsp: &[u8]) -> Option { } } r.skip(2)?; // sps_temporal_mvp_enabled_flag, strong_intra_smoothing_enabled_flag + Some(()) +} + +fn parse_vui(r: &mut Bits<'_>) -> Option { let mut signal = Signal::default(); if !r.flag()? { return Some(signal); // no VUI From 59083081aefac0d390626518512f066971420f53 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 02:58:48 +0000 Subject: [PATCH 03/39] fix(sysmem): inherit workspace Clippy denies --- crates/sysmem/Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/sysmem/Cargo.toml b/crates/sysmem/Cargo.toml index 422d8c711..a7f0492c6 100644 --- a/crates/sysmem/Cargo.toml +++ b/crates/sysmem/Cargo.toml @@ -12,6 +12,9 @@ description = "LightCraft sysmem (L0): returns freed allocator memory to the OS. unsafe_code = "deny" [lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" From 32e4874569b739ef0163dbd3f7a6daa0ace490aa Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 03:02:08 +0000 Subject: [PATCH 04/39] docs: explain workspace lint inheritance --- CLAUDE.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 416ffc0a8..6034902e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,10 +52,12 @@ of it. Full standard: `../craftrules/standards/never-crash.md` - **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native. - **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix. -- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic/indexing in tests only, and every production crate - root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, - clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Not `[workspace.lints]`: those would also hit - integration tests, examples and benches. New crates start with the attribute. +- Enforced by clippy: root `clippy.toml` keeps unwrap/expect/panic/indexing allowances scoped to tests. The workspace + denies `cognitive_complexity`, `too_many_arguments` and `wildcard_imports`; members inherit them with + `[lints] workspace = true`, and `sysmem` repeats those denies in its local lint table. Every production crate root + (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, + clippy::unimplemented, clippy::todo, clippy::unreachable)]`. New crates inherit workspace lints and start with the + attribute. ## Non-negotiables - **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…). From 38c3232214e5bea3722a2551c385306b44b504b6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 11:51:09 +0000 Subject: [PATCH 05/39] refactor(meta): isolate descriptive metadata merging --- crates/meta/src/lib.rs | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 46c7a1043..07887bbdc 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -187,7 +187,16 @@ impl Metadata { orientation, gps, width, - height, + height + ); + self.fill_missing_descriptive(other); + } + + /// Fill descriptive metadata separately from the camera and image properties. + fn fill_missing_descriptive(&mut self, other: &Metadata) { + fill!( + self, + other, artist, copyright, copyright_marked, @@ -405,11 +414,22 @@ mod tests { #[test] fn merge_rules() { - let mut a = Metadata { make: Some("A".into()), rating: Some(1), ..Default::default() }; - let b = Metadata { make: Some("B".into()), model: Some("M".into()), rating: Some(4), keywords: vec!["k".into()], ..Default::default() }; + let mut a = Metadata { make: Some("A".into()), title: Some("Original".into()), rating: Some(1), ..Default::default() }; + let b = Metadata { + make: Some("B".into()), + model: Some("M".into()), + title: Some("Replacement".into()), + caption: Some("Caption".into()), + rating: Some(4), + keywords: vec!["k".into()], + ..Default::default() + }; a.fill_missing(&b); assert_eq!(a.make.as_deref(), Some("A")); assert_eq!(a.model.as_deref(), Some("M")); + assert_eq!(a.title.as_deref(), Some("Original")); + assert_eq!(a.caption.as_deref(), Some("Caption")); + assert_eq!(a.keywords, vec!["k".to_string()]); assert_eq!(a.rating, Some(1)); a.overlay_user_fields(&b); assert_eq!(a.rating, Some(4)); From c1766c016b6d68b61750c6ac69e42bc95939c5ba Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 12:32:17 +0000 Subject: [PATCH 06/39] refactor(scenes): model water lighting parameters --- crates/scenes/src/paint.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/scenes/src/paint.rs b/crates/scenes/src/paint.rs index 4f3c4560b..32ca0f053 100644 --- a/crates/scenes/src/paint.rs +++ b/crates/scenes/src/paint.rs @@ -179,7 +179,14 @@ fn pine(p: &P, x: f32, v: f32, ground: f32, height: f32, density: f32) -> Option best } -fn water(p: &P, x: f32, v: f32, wl: f32, calm: f32, above: &dyn Fn(f32, f32) -> C, tint: C, sun: (f32, f32), sun_col: C) -> C { +struct WaterLight { + tint: C, + sun: (f32, f32), + sun_col: C, +} + +fn water(p: &P, x: f32, v: f32, wl: f32, calm: f32, above: &dyn Fn(f32, f32) -> C, light: WaterLight) -> C { + let WaterLight { tint, sun, sun_col } = light; let d = v - wl; let rip = fbm(x * 9.0, d * 60.0 / (d + 0.05), p.seed ^ 0xaa, 4) * (1.0 - calm); let rv = (wl - d * 1.0 + rip * (0.01 + 0.06 * (1.0 - calm)) * (d * 10.0 + 0.2)).max(0.0); @@ -340,7 +347,7 @@ fn alpine(p: &P, x: f32, v: f32, blue: bool) -> C { }; if v > wl { let tint = if blue { hex(0x1a2440) } else { hex(0x20303a) }; - return water(p, x, v, wl, 0.75, &above, tint, sun, sun_col); + return water(p, x, v, wl, 0.75, &above, WaterLight { tint, sun, sun_col }); } above(x, v) } @@ -419,7 +426,7 @@ fn ocean(p: &P, x: f32, v: f32) -> C { if v < hz + head * 0.3 { return hex(0x120c14); } - return water(p, x, v, hz, 0.35, &above, hex(0x1a2040), sun, sun_col); + return water(p, x, v, hz, 0.35, &above, WaterLight { tint: hex(0x1a2040), sun, sun_col }); } let _ = head; above(x, v) @@ -465,7 +472,7 @@ fn aurora(p: &P, x: f32, v: f32) -> C { add(sky(p, &sk, x, v), aur(x, v)) }; if v > wl { - return water(p, x, v, wl, 0.9, &above, hex(0x020408), (-5.0, 5.0), [0.0; 3]); + return water(p, x, v, wl, 0.9, &above, WaterLight { tint: hex(0x020408), sun: (-5.0, 5.0), sun_col: [0.0; 3] }); } above(x, v) } From 2c7d98b5e24c71d4df2fd24f090dc0877c4d69c8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 12:46:49 +0000 Subject: [PATCH 07/39] refactor(raw): separate DNG metadata and opcode parsing --- crates/raw/src/dngwrite.rs | 92 +++++----- crates/raw/src/opcodes.rs | 243 +++++++++++++++----------- crates/raw/src/tiffraw.rs | 2 +- crates/raw/tests/it/crx_properties.rs | 76 +++++--- 4 files changed, 246 insertions(+), 167 deletions(-) diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index fae6015b6..bdafb5865 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -284,48 +284,8 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { if let Some(v) = c.baseline_sharpness { ifd.set(t::BASELINE_SHARPNESS, rat_vec(&[v])); } - // the source's own profile look travels with its data (conversions, smart previews, merges) - let p = &c.profile; - let table_tags = |ifd: &mut IfdBuilder, table: &crate::profile::HsvTable, dims: u16, enc: u16| { - ifd.set(dims, Value::Long(vec![table.hue_divisions as u32, table.sat_divisions as u32, table.val_divisions as u32])); - if table.srgb_value { - ifd.set(enc, Value::Long(vec![1])); - } - }; - let flat = |table: &crate::profile::HsvTable| Value::Float(table.data.iter().flatten().copied().collect()); - match &p.hue_sat_map { - [Some(a), b] => { - table_tags(&mut ifd, a, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING); - ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(a)); - if let Some(b) = - b.as_ref().filter(|b| (b.hue_divisions, b.sat_divisions, b.val_divisions) == (a.hue_divisions, a.sat_divisions, a.val_divisions)) - { - ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_2, flat(b)); - } - } - [None, Some(b)] => { - table_tags(&mut ifd, b, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING); - ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(b)); - } - [None, None] => {} - } - if let Some(l) = &p.look_table { - table_tags(&mut ifd, l, t::PROFILE_LOOK_TABLE_DIMS, t::PROFILE_LOOK_TABLE_ENCODING); - ifd.set(t::PROFILE_LOOK_TABLE_DATA, flat(l)); - } - if let Some(curve) = &p.tone_curve { - ifd.set(t::PROFILE_TONE_CURVE, Value::Float(curve.points.iter().flatten().copied().collect())); - } - if let Some(map) = &p.gain_table_map { - // the raw IFD is IFD 0 here, where both tag versions are valid - let (version2, bytes) = map.to_bytes(opts.order); - ifd.set(if version2 { t::PROFILE_GAIN_TABLE_MAP_2 } else { t::PROFILE_GAIN_TABLE_MAP }, Value::Undefined(bytes)); - } - for (list, tag) in [(&raw.opcodes.list1, t::OPCODE_LIST_1), (&raw.opcodes.list2, t::OPCODE_LIST_2), (&raw.opcodes.list3, t::OPCODE_LIST_3)] { - if !list.is_empty() { - ifd.set(tag, Value::Undefined(opcodes::write_list(list))); - } - } + write_profile_tags(&mut ifd, raw, opts.order); + write_opcode_tags(&mut ifd, raw); match (&raw.data, opts.compression) { (RawData::F32(v), DngCompression::Deflate { tile, half }) => { @@ -495,6 +455,54 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { Ok(TiffWriter::new(opts.order, false).write(&[ifd])?) } +fn write_profile_tags(ifd: &mut IfdBuilder, raw: &RawImage, order: ByteOrder) { + // The source's own profile look travels with its data (conversions, smart previews, merges). + let profile = &raw.color.profile; + let table_tags = |ifd: &mut IfdBuilder, table: &crate::profile::HsvTable, dims: u16, enc: u16| { + ifd.set(dims, Value::Long(vec![table.hue_divisions as u32, table.sat_divisions as u32, table.val_divisions as u32])); + if table.srgb_value { + ifd.set(enc, Value::Long(vec![1])); + } + }; + let flat = |table: &crate::profile::HsvTable| Value::Float(table.data.iter().flatten().copied().collect()); + match &profile.hue_sat_map { + [Some(first), second] => { + table_tags(ifd, first, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING); + ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(first)); + if let Some(second) = second.as_ref().filter(|second| { + (second.hue_divisions, second.sat_divisions, second.val_divisions) == (first.hue_divisions, first.sat_divisions, first.val_divisions) + }) { + ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_2, flat(second)); + } + } + [None, Some(table)] => { + table_tags(ifd, table, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING); + ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(table)); + } + [None, None] => {} + } + if let Some(table) = &profile.look_table { + table_tags(ifd, table, t::PROFILE_LOOK_TABLE_DIMS, t::PROFILE_LOOK_TABLE_ENCODING); + ifd.set(t::PROFILE_LOOK_TABLE_DATA, flat(table)); + } + if let Some(curve) = &profile.tone_curve { + ifd.set(t::PROFILE_TONE_CURVE, Value::Float(curve.points.iter().flatten().copied().collect())); + } + if let Some(map) = &profile.gain_table_map { + // The raw IFD is IFD 0 here, where both tag versions are valid. + let (version2, bytes) = map.to_bytes(order); + ifd.set(if version2 { t::PROFILE_GAIN_TABLE_MAP_2 } else { t::PROFILE_GAIN_TABLE_MAP }, Value::Undefined(bytes)); + } +} + +fn write_opcode_tags(ifd: &mut IfdBuilder, raw: &RawImage) { + for (list, tag) in [(&raw.opcodes.list1, t::OPCODE_LIST_1), (&raw.opcodes.list2, t::OPCODE_LIST_2), (&raw.opcodes.list3, t::OPCODE_LIST_3)] { + if !list.is_empty() { + ifd.set(tag, Value::Undefined(opcodes::write_list(list))); + } + } +} + /// Exif `LightSource` code of a white point the rendered DNG writer knows (D65 = 21, D50 = 23). fn illuminant_of(white: lightcraft_color::Xy) -> u16 { if (white.x - lightcraft_color::D50.x).abs() < 1e-3 && (white.y - lightcraft_color::D50.y).abs() < 1e-3 { 23 } else { 21 } diff --git a/crates/raw/src/opcodes.rs b/crates/raw/src/opcodes.rs index df85e2703..f7750aaac 100644 --- a/crates/raw/src/opcodes.rs +++ b/crates/raw/src/opcodes.rs @@ -253,6 +253,9 @@ impl Rd<'_> { self.p += 8; Some(v) } + fn finite_f64(&mut self) -> Option { + self.f64().filter(|v| v.is_finite()) + } fn f32(&mut self) -> Option { let v = f32::from_be_bytes(self.d.get(self.p..self.p + 4)?.try_into().ok()?); self.p += 4; @@ -276,109 +279,126 @@ impl Rd<'_> { } fn parse_one(id: u32, flags: u32, params: &[u8]) -> Option { - let mut r = Rd { d: params, p: 0 }; - let f = |r: &mut Rd| r.f64().filter(|v| v.is_finite()); - Some(match id { + let mut reader = Rd { d: params, p: 0 }; + let parsed = match id { + 1 | 2 | 3 | 14 => parse_warp(id, flags, &mut reader)?, + 4 => Opcode::FixBadPixelsConstant { constant: reader.u32()?, bayer_phase: reader.u32()? }, + 5 => parse_bad_pixel_list(&mut reader)?, + 6 => Opcode::TrimBounds { top: reader.u32()?, left: reader.u32()?, bottom: reader.u32()?, right: reader.u32()? }, + 7..=13 => parse_area_opcode(id, &mut reader)?, + _ => Opcode::Unknown { id, flags, params: params.to_vec() }, + }; + Some(parsed) +} + +fn parse_warp(id: u32, flags: u32, reader: &mut Rd<'_>) -> Option { + match id { 1 => { - let n = r.u32()? as usize; - if n == 0 || n > 4 { - return None; - } - let mut planes = Vec::with_capacity(n); - for _ in 0..n { - planes.push([f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?]); - } - Opcode::WarpRectilinear { planes, center: [f(&mut r)?, f(&mut r)?] } + let planes = parse_planes(reader, 4, |r| { + Some([r.finite_f64()?, r.finite_f64()?, r.finite_f64()?, r.finite_f64()?, r.finite_f64()?, r.finite_f64()?]) + })?; + Some(Opcode::WarpRectilinear { planes, center: [reader.finite_f64()?, reader.finite_f64()?] }) } 2 => { - let n = r.u32()? as usize; - if n == 0 || n > 4 { - return None; - } - let mut planes = Vec::with_capacity(n); - for _ in 0..n { - planes.push([f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?]); - } - Opcode::WarpFisheye { planes, center: [f(&mut r)?, f(&mut r)?] } + let planes = parse_planes(reader, 4, |r| Some([r.finite_f64()?, r.finite_f64()?, r.finite_f64()?, r.finite_f64()?]))?; + Some(Opcode::WarpFisheye { planes, center: [reader.finite_f64()?, reader.finite_f64()?] }) } - 14 => { - let n = r.u32()? as usize; - // N sets of 19 doubles, then the centre (2 doubles) and reciprocalRadial (LONG); nothing else - if n == 0 || n > WR2_MAX_PLANES || params.len() != 4 + n * WR2_LEN * 8 + 16 + 4 { - return None; - } - let mut planes = Vec::with_capacity(n); - for _ in 0..n { - let mut k = [0.0; WR2_LEN]; - for v in k.iter_mut() { - *v = f(&mut r).filter(|v| v.abs() <= WR2_MAX_COEF)?; - } - // the spec requires 0 <= min_valid_radius < max_valid_radius <= 1 - if !(k[17] >= 0.0 && k[17] < k[18] && k[18] <= 1.0) { - return None; - } - planes.push(k); - } - let center = [f(&mut r).filter(|v| v.abs() <= 4.0)?, f(&mut r).filter(|v| v.abs() <= 4.0)?]; - Opcode::WarpRectilinear2 { planes, center, reciprocal: r.u32()? != 0, optional: flags & 1 != 0 } + 3 => Some(Opcode::FixVignetteRadial { + k: [reader.finite_f64()?, reader.finite_f64()?, reader.finite_f64()?, reader.finite_f64()?, reader.finite_f64()?], + center: [reader.finite_f64()?, reader.finite_f64()?], + }), + 14 => parse_warp_rectilinear2(flags, reader), + _ => None, + } +} + +fn parse_planes(reader: &mut Rd<'_>, max: usize, mut parse: impl FnMut(&mut Rd<'_>) -> Option<[f64; N]>) -> Option> { + let count = reader.u32()? as usize; + if count == 0 || count > max { + return None; + } + (0..count).map(|_| parse(reader)).collect() +} + +fn parse_warp_rectilinear2(flags: u32, reader: &mut Rd<'_>) -> Option { + let count = reader.u32()? as usize; + // N sets of 19 doubles, then the centre (2 doubles) and reciprocalRadial (LONG); nothing else. + if count == 0 || count > WR2_MAX_PLANES || reader.remaining() != count * WR2_LEN * 8 + 16 + 4 { + return None; + } + let mut planes = Vec::with_capacity(count); + for _ in 0..count { + let mut coefficients = [0.0; WR2_LEN]; + for value in &mut coefficients { + *value = reader.finite_f64().filter(|value| value.abs() <= WR2_MAX_COEF)?; } - 3 => Opcode::FixVignetteRadial { k: [f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?], center: [f(&mut r)?, f(&mut r)?] }, - 4 => Opcode::FixBadPixelsConstant { constant: r.u32()?, bayer_phase: r.u32()? }, - 5 => { - let bayer_phase = r.u32()?; - let (np, nr) = (r.u32()? as usize, r.u32()? as usize); - if np.saturating_mul(8).saturating_add(nr.saturating_mul(16)) > r.remaining() { - return None; - } - let points = (0..np).map(|_| Some((r.u32()?, r.u32()?))).collect::>()?; - let rects = (0..nr).map(|_| Some([r.u32()?, r.u32()?, r.u32()?, r.u32()?])).collect::>()?; - Opcode::FixBadPixelsList { bayer_phase, points, rects } + if !(coefficients[17] >= 0.0 && coefficients[17] < coefficients[18] && coefficients[18] <= 1.0) { + return None; } - 6 => Opcode::TrimBounds { top: r.u32()?, left: r.u32()?, bottom: r.u32()?, right: r.u32()? }, + planes.push(coefficients); + } + let center = [reader.finite_f64().filter(|value| value.abs() <= 4.0)?, reader.finite_f64().filter(|value| value.abs() <= 4.0)?]; + Some(Opcode::WarpRectilinear2 { planes, center, reciprocal: reader.u32()? != 0, optional: flags & 1 != 0 }) +} + +fn parse_bad_pixel_list(reader: &mut Rd<'_>) -> Option { + let bayer_phase = reader.u32()?; + let (point_count, rect_count) = (reader.u32()? as usize, reader.u32()? as usize); + if point_count.saturating_mul(8).saturating_add(rect_count.saturating_mul(16)) > reader.remaining() { + return None; + } + let points = (0..point_count).map(|_| Some((reader.u32()?, reader.u32()?))).collect::>()?; + let rects = (0..rect_count).map(|_| Some([reader.u32()?, reader.u32()?, reader.u32()?, reader.u32()?])).collect::>()?; + Some(Opcode::FixBadPixelsList { bayer_phase, points, rects }) +} + +fn parse_area_opcode(id: u32, reader: &mut Rd<'_>) -> Option { + let area = reader.area()?; + match id { 7 => { - let area = r.area()?; - let n = r.u32()? as usize; - if n == 0 || n > 65536 || n * 2 > r.remaining() { + let count = reader.u32()? as usize; + if count == 0 || count > 65536 || count * 2 > reader.remaining() { return None; } - Opcode::MapTable { area, table: (0..n).map(|_| r.u16()).collect::>()? } + Some(Opcode::MapTable { area, table: (0..count).map(|_| reader.u16()).collect::>()? }) } 8 => { - let area = r.area()?; - let deg = r.u32()? as usize; - if deg > 8 { + let degree = reader.u32()? as usize; + if degree > 8 { return None; } - Opcode::MapPolynomial { area, coefficients: (0..=deg).map(|_| f(&mut r)).collect::>()? } + Some(Opcode::MapPolynomial { area, coefficients: (0..=degree).map(|_| reader.finite_f64()).collect::>()? }) } - 9 => { - let area = r.area()?; - let (pv, ph) = (r.u32()?, r.u32()?); - let spacing = [f(&mut r)?, f(&mut r)?]; - let origin = [f(&mut r)?, f(&mut r)?]; - let mp = r.u32()?; - let n = (pv as usize).checked_mul(ph as usize)?.checked_mul(mp as usize)?; - if n == 0 || n * 4 > r.remaining() { - return None; - } - let gains = (0..n).map(|_| r.f32()).collect::>()?; - Opcode::GainMap { area, points_v: pv, points_h: ph, spacing, origin, map_planes: mp, gains } - } - 10..=13 => { - let area = r.area()?; - let n = r.u32()? as usize; - if n * 4 > r.remaining() { - return None; - } - let v: Vec = (0..n).map(|_| r.f32()).collect::>()?; - match id { - 10 => Opcode::DeltaPerRow { area, deltas: v }, - 11 => Opcode::DeltaPerColumn { area, deltas: v }, - 12 => Opcode::ScalePerRow { area, scales: v }, - _ => Opcode::ScalePerColumn { area, scales: v }, - } - } - _ => Opcode::Unknown { id, flags, params: params.to_vec() }, + 9 => parse_gain_map(area, reader), + 10..=13 => parse_delta_scale(id, area, reader), + _ => None, + } +} + +fn parse_gain_map(area: Area, reader: &mut Rd<'_>) -> Option { + let (points_v, points_h) = (reader.u32()?, reader.u32()?); + let spacing = [reader.finite_f64()?, reader.finite_f64()?]; + let origin = [reader.finite_f64()?, reader.finite_f64()?]; + let map_planes = reader.u32()?; + let count = (points_v as usize).checked_mul(points_h as usize)?.checked_mul(map_planes as usize)?; + if count == 0 || count * 4 > reader.remaining() { + return None; + } + let gains = (0..count).map(|_| reader.f32()).collect::>()?; + Some(Opcode::GainMap { area, points_v, points_h, spacing, origin, map_planes, gains }) +} + +fn parse_delta_scale(id: u32, area: Area, reader: &mut Rd<'_>) -> Option { + let count = reader.u32()? as usize; + if count * 4 > reader.remaining() { + return None; + } + let values: Vec = (0..count).map(|_| reader.f32()).collect::>()?; + Some(match id { + 10 => Opcode::DeltaPerRow { area, deltas: values }, + 11 => Opcode::DeltaPerColumn { area, deltas: values }, + 12 => Opcode::ScalePerRow { area, scales: values }, + _ => Opcode::ScalePerColumn { area, scales: values }, }) } @@ -508,19 +528,29 @@ fn area_iter(a: &Area, w: usize, h: usize, cpp: usize) -> impl Iterator f32 { - let (pv, ph) = (points_v as usize, points_h as usize); - let mp = map_planes as usize; - let p = plane.min(mp - 1); - let fy = if spacing[0] > 0.0 { ((rv - origin[0]) / spacing[0]).clamp(0.0, (pv - 1) as f64) } else { 0.0 }; - let fx = if spacing[1] > 0.0 { ((rh - origin[1]) / spacing[1]).clamp(0.0, (ph - 1) as f64) } else { 0.0 }; - let (y0, x0) = (fy.floor() as usize, fx.floor() as usize); - let (y1, x1) = ((y0 + 1).min(pv - 1), (x0 + 1).min(ph - 1)); - let (ty, tx) = ((fy - y0 as f64) as f32, (fx - x0 as f64) as f32); - let g = |y: usize, x: usize| gains[(y * ph + x) * mp + p]; - let top = g(y0, x0) + (g(y0, x1) - g(y0, x0)) * tx; - let bot = g(y1, x0) + (g(y1, x1) - g(y1, x0)) * tx; - top + (bot - top) * ty +struct GainMapGrid<'a> { + points: [usize; 2], + spacing: [f64; 2], + origin: [f64; 2], + planes: usize, + gains: &'a [f32], +} + +impl GainMapGrid<'_> { + fn sample_at(&self, position: [f64; 2], plane: usize) -> f32 { + let [pv, ph] = self.points; + let mp = self.planes; + let p = plane.min(mp - 1); + let fy = if self.spacing[0] > 0.0 { ((position[0] - self.origin[0]) / self.spacing[0]).clamp(0.0, (pv - 1) as f64) } else { 0.0 }; + let fx = if self.spacing[1] > 0.0 { ((position[1] - self.origin[1]) / self.spacing[1]).clamp(0.0, (ph - 1) as f64) } else { 0.0 }; + let (y0, x0) = (fy.floor() as usize, fx.floor() as usize); + let (y1, x1) = ((y0 + 1).min(pv - 1), (x0 + 1).min(ph - 1)); + let (ty, tx) = ((fy - y0 as f64) as f32, (fx - x0 as f64) as f32); + let g = |y: usize, x: usize| self.gains[(y * ph + x) * mp + p]; + let top = g(y0, x0) + (g(y0, x1) - g(y0, x0)) * tx; + let bot = g(y1, x0) + (g(y1, x1) - g(y1, x0)) * tx; + top + (bot - top) * ty + } } /// Radius normaliser: distance from the centre to the farthest image corner. @@ -556,10 +586,17 @@ pub fn apply_list(list: &[Opcode], buf: &mut [f32], w: usize, h: usize, cpp: usi } } Opcode::GainMap { area, points_v, points_h, spacing, origin, map_planes, gains } => { + let grid = GainMapGrid { + points: [*points_v as usize, *points_h as usize], + spacing: *spacing, + origin: *origin, + planes: *map_planes as usize, + gains, + }; for (x, y, p) in area_iter(area, w, h, cpp) { let rv = y as f64 / h as f64; let rh = x as f64 / w as f64; - let g = gain_at(*points_v, *points_h, *spacing, *origin, *map_planes, gains, rv, rh, p - area.plane as usize); + let g = grid.sample_at([rv, rh], p - area.plane as usize); buf[(y * w + x) * cpp + p] *= g; } } diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs index 57878b881..b6a1971ce 100644 --- a/crates/raw/src/tiffraw.rs +++ b/crates/raw/src/tiffraw.rs @@ -1,6 +1,6 @@ //! Reading the pixel data of a TIFF IFD (strips or tiles; uncompressed, lossless/lossy JPEG, Deflate, JPEG XL), in parallel. -use crate::unpack::*; +use crate::unpack::{f16_to_f32, f24_to_f32, inflate, read_u16s, undo_diff_u16, undo_float_predictor, unpack_lsb, unpack_msb, unpack_words32_msb}; use crate::{MAX_SAMPLES, RawData, RawError, Result, ljpeg}; use lightcraft_tiff::image::{Chunk, ImageInfo, chunk_bytes}; use lightcraft_tiff::{ByteOrder, tags::compression as comp}; diff --git a/crates/raw/tests/it/crx_properties.rs b/crates/raw/tests/it/crx_properties.rs index 804970de4..ec61aa3b8 100644 --- a/crates/raw/tests/it/crx_properties.rs +++ b/crates/raw/tests/it/crx_properties.rs @@ -632,20 +632,23 @@ fn lossless_file((width, height): (usize, usize), values: &[i32]) -> Fixture { /// Constant-coefficient planes for one tile: LL3 flat at `ll3`, band `mid_band.0` flat at `mid_band.1`, /// everything else zero. Encoded at the given level count with the given quant/gain/base parameters. -fn constant_band_planes( - plane_w: usize, - plane_h: usize, +struct ConstantBandPlaneConfig<'a> { + plane_size: (usize, usize), levels: u8, ll3: i32, mid_band: Option<(usize, i32)>, quant: u8, gains: [u32; 10], bases: [u32; 10], - qp: Option<&[i32]>, + qp: Option<&'a [i32]>, qp_padding: usize, - before: bool, - after: bool, -) -> (usize, Vec) { + boundary_bands: (bool, bool), +} + +fn constant_band_planes(config: ConstantBandPlaneConfig<'_>) -> (usize, Vec) { + let ConstantBandPlaneConfig { plane_size: (plane_w, plane_h), levels, ll3, mid_band, quant, gains, bases, qp, qp_padding, boundary_bands } = + config; + let (before, after) = boundary_bands; let planes: [Vec>; 4] = if levels == 0 { let mut encoded: [Vec>; 4] = Default::default(); for bands in encoded.iter_mut() { @@ -681,7 +684,18 @@ fn constant_band_planes( /// A 16×16 three-level C-RAW file built from constant bands at the given uniform quant value. fn constant_bands_file(ll3: i32, mid_band: Option<(usize, i32)>, quant: u8) -> Fixture { - let (header_size, sample) = constant_band_planes(8, 8, 3, ll3, mid_band, quant, [0; 10], [0; 10], None, 0, false, false); + let (header_size, sample) = constant_band_planes(ConstantBandPlaneConfig { + plane_size: (8, 8), + levels: 3, + ll3, + mid_band, + quant, + gains: [0; 10], + bases: [0; 10], + qp: None, + qp_padding: 0, + boundary_bands: (false, false), + }); let coding = cmp1(0x100, 16, 16, 16, 16, 3, header_size as u32); Fixture { file: wrap(&coding, 16, 16, &sample, None), expected: Vec::new() } } @@ -697,7 +711,18 @@ fn v200_mapped_file( bases: [u32; 10], ) -> Fixture { assert_eq!(map.len(), (width / 16) * (height / 4)); - let (header_size, sample) = constant_band_planes(width / 2, height / 2, 3, ll3, mid_band, 4, gains, bases, Some(map), 3, false, false); + let (header_size, sample) = constant_band_planes(ConstantBandPlaneConfig { + plane_size: (width / 2, height / 2), + levels: 3, + ll3, + mid_band, + quant: 4, + gains, + bases, + qp: Some(map), + qp_padding: 3, + boundary_bands: (false, false), + }); let coding = cmp1(0x200, width as u32, height as u32, width as u32, height as u32, 3, header_size as u32); Fixture { file: wrap(&coding, width as u32, height as u32, &sample, None), expected: Vec::new() } } @@ -811,20 +836,18 @@ proptest! { } *b = if i < 4 { base + 1 } else { base }; } - let (header_size, sample) = constant_band_planes( - width / 2, - height / 2, - 3, + let (header_size, sample) = constant_band_planes(ConstantBandPlaneConfig { + plane_size: (width / 2, height / 2), + levels: 3, ll3, - Some((4, mid)), - 4, + mid_band: Some((4, mid)), + quant: 4, gains, bases, - Some(&qp_values[..map_w * map_h]), - 3, - false, - false, - ); + qp: Some(&qp_values[..map_w * map_h]), + qp_padding: 3, + boundary_bands: (false, false), + }); let coding = cmp1(0x200, width as u32, height as u32, width as u32, height as u32, 3, header_size as u32); let file = wrap(&coding, width as u32, height as u32, &sample, None); let image = decode(&file).unwrap(); @@ -879,7 +902,18 @@ fn lossless_sample(value: i32) -> Vec { /// Raw sample bytes of a constant-band 16×16 C-RAW fixture. fn constant_bands_sample(ll3: i32, mid_band: Option<(usize, i32)>, quant: u8) -> Vec { - let (_, sample) = constant_band_planes(8, 8, 3, ll3, mid_band, quant, [0; 10], [0; 10], None, 0, false, false); + let (_, sample) = constant_band_planes(ConstantBandPlaneConfig { + plane_size: (8, 8), + levels: 3, + ll3, + mid_band, + quant, + gains: [0; 10], + bases: [0; 10], + qp: None, + qp_padding: 0, + boundary_bands: (false, false), + }); sample } From 196a0c1a4513257d00c289fb2b15e7b984ca5e88 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 12:59:22 +0000 Subject: [PATCH 08/39] docs: require lint inheritance for new crates --- CLAUDE.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6034902e9..1ff3defb0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,8 +56,9 @@ of it. Full standard: `../craftrules/standards/never-crash.md` denies `cognitive_complexity`, `too_many_arguments` and `wildcard_imports`; members inherit them with `[lints] workspace = true`, and `sysmem` repeats those denies in its local lint table. Every production crate root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, - clippy::unimplemented, clippy::todo, clippy::unreachable)]`. New crates inherit workspace lints and start with the - attribute. + clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Every new workspace crate must include + `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`, and new production crate roots start with + the attribute. ## Non-negotiables - **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…). From 2f42ff894455f655630c7c2ce269ec801c4e78c5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:19:08 +0000 Subject: [PATCH 09/39] refactor(pipeline): group pixel finish parameters --- crates/engine/src/media/render.rs | 4 +- crates/gpu/src/render.rs | 12 +- crates/pipeline/src/finish.rs | 315 +++++++++++++++--------------- 3 files changed, 169 insertions(+), 162 deletions(-) diff --git a/crates/engine/src/media/render.rs b/crates/engine/src/media/render.rs index 1192a98e0..a87a0792c 100644 --- a/crates/engine/src/media/render.rs +++ b/crates/engine/src/media/render.rs @@ -1,6 +1,8 @@ use super::quick::QuickSource; use super::source::{DecodedSource, SourceLevel, SourceRef}; -use super::*; +use super::{ + Arc, DevelopSettings, Hash128, Histogram, PhotoId, PreviewCache, Quality, RenderRequest, Rendered, Rgb32f, Rgba8, Source, SourceInfo, StageCache, +}; /// Everything needed to render one photo, detached from the session. #[derive(Clone)] diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 64990db0e..90aed7974 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex}; use lightcraft_develop::DevelopSettings; use lightcraft_geom::Orientation; -use lightcraft_pipeline::finish::{FinishParams, MASK_TERMS, mask_terms}; +use lightcraft_pipeline::finish::{FinishParams, FinishParamsInput, MASK_TERMS, mask_terms}; use lightcraft_pipeline::geometry::SampleMode; use lightcraft_pipeline::local::plane_at; use lightcraft_pipeline::{Plan, RenderRequest, Rendered, SourceInfo, local}; @@ -668,7 +668,15 @@ pub fn render( lap("masks", &mut t, &mut cx); // 5. per-pixel stage - let mut fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space); + let mut fp = FinishParams::new(FinishParamsInput { + settings: s, + frame: &plan.frame, + info, + image_size: (w, h), + px_per_long: plan.px_per_long, + air_pre: prep.air, + space: req.space, + }); // soft proofing as the CPU's `finish` sets it up (an output-device table never gets here: see `render`) fp.proof = req.proof.map(|pr| pr.params(req.space)); if let Some(d) = &req.display { diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index c1e6a9bc2..4fccd1134 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -186,19 +186,20 @@ pub struct FinishParams { pub peak: f32, } +pub struct FinishParamsInput<'a> { + pub settings: &'a DevelopSettings, + pub frame: &'a Frame, + pub info: &'a SourceInfo, + pub image_size: (usize, usize), + pub px_per_long: f64, + pub air_pre: f32, + pub space: OutputSpace, +} + impl FinishParams { /// Parameters for a `w × h` render into `space`; `ev`/`air_pre` as in [`crate::Prepared`]. - #[allow(clippy::too_many_arguments)] - pub fn new( - s: &DevelopSettings, - frame: &Frame, - info: &SourceInfo, - w: usize, - h: usize, - px_per_long: f64, - air_pre: f32, - space: OutputSpace, - ) -> FinishParams { + pub fn new(input: FinishParamsInput<'_>) -> FinishParams { + let FinishParamsInput { settings: s, frame, info, image_size: (w, h), px_per_long, air_pre, space } = input; let effects = s.section_enabled("effects"); let (clar, tex, dehaze) = if effects { ((s.effects.clarity / 100.0) as f32, (s.effects.texture / 100.0) as f32, (s.effects.dehaze / 100.0) as f32) @@ -288,7 +289,8 @@ pub(crate) fn finish( proof: Option, ) -> Rgba8 { let (w, h) = (p.img.width, p.img.height); - let mut fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space); + let mut fp = + FinishParams::new(FinishParamsInput { settings: s, frame, info, image_size: (w, h), px_per_long: p.px_per_long, air_pre: p.air, space }); fp.proof = proof.map(|pr| pr.params(space)); if let Some(d) = display { fp.for_display(d, proof); @@ -305,7 +307,6 @@ pub(crate) fn finish( } /// [`finish`] into 16-bit display-encoded or 32-bit float linear samples (output primaries). -#[allow(clippy::too_many_arguments)] pub(crate) fn finish_deep( p: &Prepared, s: &DevelopSettings, @@ -317,7 +318,8 @@ pub(crate) fn finish_deep( ) -> DeepImage { use lightcraft_color::transfer::srgb_to_linear; let (w, h) = (p.img.width, p.img.height); - let mut fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space); + let mut fp = + FinishParams::new(FinishParamsInput { settings: s, frame, info, image_size: (w, h), px_per_long: p.px_per_long, air_pre: p.air, space }); fp.proof = proof.map(|pr| pr.params(space)); let trc = fp.out_trc; let samples = match depth { @@ -354,29 +356,7 @@ pub(crate) fn finish_with( ) -> Vec { let (w, h) = (p.img.width, p.img.height); let p_lut = fp.lut.clone(); - let FinishParams { - tone, - ops, - curves, - vig, - to_out, - out_luma, - hl, - sh, - clar, - tex, - dehaze, - sharpen, - sharpen_mask, - air, - air_pre, - gain, - ev, - grain, - .. - } = fp; - let (hl, sh, clar, tex, dehaze, sharpen, sharpen_mask, air, air_pre, gain, ev) = - (*hl, *sh, *clar, *tex, *dehaze, *sharpen, *sharpen_mask, *air, *air_pre, *gain, *ev); + let FinishParams { curves, vig, to_out, out_luma, grain, .. } = fp; let terms: Vec<[f32; MASK_TERMS]> = p.masks.iter().map(|m| mask_terms(&m.adjust)).collect(); let out_to_norm = fp.out_to_norm; let long = fp.ow.max(fp.oh); @@ -392,128 +372,17 @@ pub(crate) fn finish_with( for (x, px) in row.iter_mut().enumerate() { let i = y * w + x; let raw = p.img.data[i]; - let mut c = if gain == 1.0 { raw } else { raw.map(|v| v * gain) }; + let local = pixel_local_terms(p, &terms, i); + let l_sat = local.values[11]; + let l_hue = local.values[12]; let l_pre = p.log_l.data[i]; - let l0 = l_pre + ev; - - // --- local (mask) contributions: alpha-weighted sums of the masks' terms - let mut l = [0.0f32; MASK_SUMS]; - let mut tint_col: Option<([f32; 3], f32)> = None; - for (m, t) in p.masks.iter().zip(&terms) { - let a = m.alpha.data[i]; - if a <= 0.0 { - continue; - } - for k in 0..MASK_SUMS { - l[k] += a * t[k]; - } - if t[MASK_SUMS] > 0.0 { - tint_col = Some(([t[MASK_SUMS + 1], t[MASK_SUMS + 2], 0.0], a * t[MASK_SUMS + 3])); - } - } - let [l_exp, l_temp, l_tint, l_con, l_hl, l_sh, l_wh, l_bl, l_tex, l_clar, l_dehaze, l_sat, l_hue, l_sharp, l_noise, l_moire, l_defringe] = - l; - - // --- local Moiré (and the colour part of Noise): chromaticity towards its blur - let mt = (l_moire + 0.5 * l_noise.max(0.0)).clamp(-1.0, 1.0); - if mt != 0.0 - && let Some(cb) = &p.chroma_blur - { - let (y, ch0, cb) = (luminance_2020(c), crate::masks::chromaticity(raw), cb.data[i]); - c = std::array::from_fn(|k| ((ch0[k] + (cb[k] - ch0[k]) * mt) * y).max(0.0)); - } - // --- local Defringe: desaturate purple / green fringes along edges - let df = l_defringe.clamp(0.0, 1.0); - if df > 0.0 - && let Some(b) = &p.texture_blur - { - let k = df * defringe_weight(c, p.log_l.data[i] - b.data[i]); - if k > 0.0 { - let y = luminance_2020(c); - c = c.map(|v| v + (y - v) * k); - } - } - - // --- dehaze (scene linear) - let dz = dehaze + l_dehaze; - if dz != 0.0 - && let Some(dark) = &p.dark - { - // The dark channel is a blur, not a patch minimum: near bright neighbours it credits - // dark pixels with veil they don't hold (and a windowed render borrows the whole - // frame's airlight), so the subtraction went past zero and crushed them to black - // (#2504). Never attribute more veil than the pixel's own minimum channel. - let c_min = c[0].min(c[1]).min(c[2]); - let d = (if air_pre > 0.0 { dark.data[i] / air_pre } else { 0.0 }).min(if air > 0.0 { c_min / air } else { 0.0 }).clamp(0.0, 1.0); - if dz > 0.0 { - let t = (1.0 - 0.95 * dz.min(1.0) * d).max(0.12); - c = c.map(|v| ((v - air * (1.0 - t)) / t).max(0.0)); - } else { - let k = (-dz).min(1.0) * 0.7 * (0.35 + 0.65 * d); - c = c.map(|v| v + (air * 0.9 - v) * k); - } - } - - // --- local exposure / temp / tint - if l_exp != 0.0 { - let g = l_exp.exp2(); - c = c.map(|v| v * g); - } - if l_temp != 0.0 || l_tint != 0.0 { - let y0 = luminance_2020(c); - c = [c[0] * (1.0 + 0.3 * l_temp), c[1] * (1.0 - 0.22 * l_tint), c[2] * (1.0 - 0.3 * l_temp).max(0.0)]; - let y1 = luminance_2020(c).max(1e-9); - c = c.map(|v| v * y0 / y1); - } + let l0 = l_pre + fp.ev; + let (mut c, l1) = adjust_scene_pixel(p, fp, i, raw, &local); // --- local tone in log luminance - let l1 = if dz != 0.0 || l_exp != 0.0 { log_lum(c) } else { l0 }; let shift = l1 - l0; - let base = p.base.data[i] + ev + shift; - let mut delta = 0.0f32; - let (hh, ss) = (hl + l_hl, sh + l_sh); - if hh != 0.0 || ss != 0.0 { - let ws = 1.0 - smooth(-4.8, 0.3, base); - let wh = smooth(-1.0, 2.8, base); - delta += ss * 1.7 * ws * ws.sqrt() + hh * 1.7 * wh; - } - if l_wh != 0.0 { - delta += l_wh * 0.8 * smooth(0.5, 3.0, l1); - } - if l_bl != 0.0 { - delta += l_bl * 0.8 * (1.0 - smooth(-6.0, -1.5, l1)); - } - if l_con != 0.0 { - delta += l_con * 0.14 * l1.clamp(-6.0, 4.0); - } - let cl = clar + l_clar; - if cl != 0.0 - && let Some(b) = &p.clarity_blur - { - let det = (l_pre - b.data[i]).clamp(-2.5, 2.5); - let mid = (-(base / 3.2).powi(2)).exp(); - delta += cl * 0.85 * det * (0.35 + 0.65 * mid); - } - let tx = tex + l_tex; - let sp = l_sharp * 0.6 + sharpen; - if (tx != 0.0 || sp != 0.0) - && let Some(b) = &p.texture_blur - { - let det = l_pre - b.data[i]; - 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; - } - } - // local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges - if l_noise != 0.0 - && let Some(b) = &p.texture_blur - { - let det = l_pre - b.data[i]; - delta -= l_noise.clamp(-1.0, 1.0) * 0.9 * det * (1.0 - smooth(0.1, 0.5, det.abs())); - } + let base = p.base.data[i] + fp.ev + shift; + let delta = local_tone_delta(p, fp, i, base, l1, &local); if delta != 0.0 { let g = delta.exp2(); c = c.map(|v| v * g); @@ -526,9 +395,9 @@ pub(crate) fn finish_with( // --- tone map on luminance, highlight desaturation let yl = luminance_2020(c); - let o = tone.apply(yl); + let o = fp.tone.apply(yl); let mut d = if yl > 1e-9 { c.map(|v| v * o / yl) } else { [0.0; 3] }; - let k = tone.chroma_scale(o); + let k = fp.tone.chroma_scale(o); if k != 1.0 { d = d.map(|v| o + (v - o) * k); } @@ -539,8 +408,8 @@ pub(crate) fn finish_with( } // --- colour - d = ops.apply(d, l_sat, l_hue); - if let Some((dir, amt)) = tint_col { + d = fp.ops.apply(d, l_sat, l_hue); + if let Some((dir, amt)) = local.tint { let lab = lightcraft_color::perceptual::oklab_from_2020(d); d = lightcraft_color::perceptual::oklab_to_2020([lab[0], lab[1] + dir[0] * 0.08 * amt, lab[2] + dir[1] * 0.08 * amt]); } @@ -663,6 +532,134 @@ pub(crate) fn finish_with( out } +struct PixelLocalTerms { + values: [f32; MASK_SUMS], + tint: Option<([f32; 3], f32)>, +} + +fn pixel_local_terms(p: &Prepared, terms: &[[f32; MASK_TERMS]], index: usize) -> PixelLocalTerms { + let mut values = [0.0; MASK_SUMS]; + let mut tint = None; + for (mask, terms) in p.masks.iter().zip(terms) { + let alpha = mask.alpha.data[index]; + if alpha <= 0.0 { + continue; + } + for (sum, term) in values.iter_mut().zip(terms.iter().take(MASK_SUMS)) { + *sum += alpha * term; + } + if terms[MASK_SUMS] > 0.0 { + tint = Some(([terms[MASK_SUMS + 1], terms[MASK_SUMS + 2], 0.0], alpha * terms[MASK_SUMS + 3])); + } + } + PixelLocalTerms { values, tint } +} + +fn adjust_scene_pixel(p: &Prepared, fp: &FinishParams, index: usize, raw: [f32; 3], local: &PixelLocalTerms) -> ([f32; 3], f32) { + let [l_exp, l_temp, l_tint, _, _, _, _, _, _, _, l_dehaze, _, _, _, l_noise, l_moire, l_defringe] = local.values; + let mut color = if fp.gain == 1.0 { raw } else { raw.map(|value| value * fp.gain) }; + + let moire = (l_moire + 0.5 * l_noise.max(0.0)).clamp(-1.0, 1.0); + if moire != 0.0 + && let Some(blur) = &p.chroma_blur + { + let (luminance, original, blurred) = (luminance_2020(color), crate::masks::chromaticity(raw), blur.data[index]); + color = std::array::from_fn(|channel| ((original[channel] + (blurred[channel] - original[channel]) * moire) * luminance).max(0.0)); + } + let defringe = l_defringe.clamp(0.0, 1.0); + if defringe > 0.0 + && let Some(blur) = &p.texture_blur + { + let amount = defringe * defringe_weight(color, p.log_l.data[index] - blur.data[index]); + if amount > 0.0 { + let luminance = luminance_2020(color); + color = color.map(|value| value + (luminance - value) * amount); + } + } + + let dehaze = fp.dehaze + l_dehaze; + if dehaze != 0.0 + && let Some(dark) = &p.dark + { + // Limit the estimated veil to the pixel's own minimum channel. This avoids crushing bright + // neighbours when the dark-channel blur spans the edge of a local window. + let min_channel = color[0].min(color[1]).min(color[2]); + let veil = (if fp.air_pre > 0.0 { dark.data[index] / fp.air_pre } else { 0.0 }) + .min(if fp.air > 0.0 { min_channel / fp.air } else { 0.0 }) + .clamp(0.0, 1.0); + if dehaze > 0.0 { + let transmission = (1.0 - 0.95 * dehaze.min(1.0) * veil).max(0.12); + color = color.map(|value| ((value - fp.air * (1.0 - transmission)) / transmission).max(0.0)); + } else { + let amount = (-dehaze).min(1.0) * 0.7 * (0.35 + 0.65 * veil); + color = color.map(|value| value + (fp.air * 0.9 - value) * amount); + } + } + + if l_exp != 0.0 { + let gain = l_exp.exp2(); + color = color.map(|value| value * gain); + } + if l_temp != 0.0 || l_tint != 0.0 { + let luminance_before = luminance_2020(color); + color = [color[0] * (1.0 + 0.3 * l_temp), color[1] * (1.0 - 0.22 * l_tint), color[2] * (1.0 - 0.3 * l_temp).max(0.0)]; + let luminance_after = luminance_2020(color).max(1e-9); + color = color.map(|value| value * luminance_before / luminance_after); + } + + let l0 = p.log_l.data[index] + fp.ev; + let log_luminance = if dehaze != 0.0 || l_exp != 0.0 { log_lum(color) } else { l0 }; + (color, log_luminance) +} + +fn local_tone_delta(p: &Prepared, fp: &FinishParams, index: usize, base: f32, l1: f32, local: &PixelLocalTerms) -> f32 { + let [_, _, _, l_con, l_hl, l_sh, l_wh, l_bl, l_tex, l_clar, _, _, _, l_sharp, l_noise, _, _] = local.values; + let mut delta = 0.0; + let (highlights, shadows) = (fp.hl + l_hl, fp.sh + l_sh); + if highlights != 0.0 || shadows != 0.0 { + let shadow_weight = 1.0 - smooth(-4.8, 0.3, base); + let highlight_weight = smooth(-1.0, 2.8, base); + delta += shadows * 1.7 * shadow_weight * shadow_weight.sqrt() + highlights * 1.7 * highlight_weight; + } + if l_wh != 0.0 { + delta += l_wh * 0.8 * smooth(0.5, 3.0, l1); + } + if l_bl != 0.0 { + delta += l_bl * 0.8 * (1.0 - smooth(-6.0, -1.5, l1)); + } + if l_con != 0.0 { + delta += l_con * 0.14 * l1.clamp(-6.0, 4.0); + } + let clarity = fp.clar + l_clar; + if clarity != 0.0 + && let Some(blur) = &p.clarity_blur + { + let detail = (p.log_l.data[index] - blur.data[index]).clamp(-2.5, 2.5); + let midtone = (-(base / 3.2).powi(2)).exp(); + delta += clarity * 0.85 * detail * (0.35 + 0.65 * midtone); + } + let texture = fp.tex + l_tex; + let sharpen = l_sharp * 0.6 + fp.sharpen; + if (texture != 0.0 || sharpen != 0.0) + && let Some(blur) = &p.texture_blur + { + let detail = p.log_l.data[index] - blur.data[index]; + let tame = 1.0 - 0.6 * smooth(0.4, 1.6, detail.abs()); + delta += texture * 1.1 * detail.clamp(-1.0, 1.0) * tame; + if sharpen != 0.0 { + let mask = if fp.sharpen_mask > 0.0 { smooth(fp.sharpen_mask * 0.25, fp.sharpen_mask * 0.25 + 0.15, detail.abs()) } else { 1.0 }; + delta += sharpen * 1.3 * detail.clamp(-0.8, 0.8) * mask; + } + } + if l_noise != 0.0 + && let Some(blur) = &p.texture_blur + { + let detail = p.log_l.data[index] - blur.data[index]; + delta -= l_noise.clamp(-1.0, 1.0) * 0.9 * detail * (1.0 - smooth(0.1, 0.5, detail.abs())); + } + delta +} + /// A colour needing more desaturation than this (scale < `GAMUT_WARN`) to fit is out of gamut for /// the gamut warnings (tolerates rounding at the gamut boundary). const GAMUT_WARN: f32 = 0.995; From a3da2d1040b2c48b130b6eca96bb533742585ee6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:19:09 +0000 Subject: [PATCH 10/39] refactor(denoise): model Bayer tile regions --- crates/denoise-core/src/bayer.rs | 25 ++++++++++++++++++------- crates/denoise-core/src/run.rs | 9 +++++++-- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/crates/denoise-core/src/bayer.rs b/crates/denoise-core/src/bayer.rs index a4a00d947..365bec436 100644 --- a/crates/denoise-core/src/bayer.rs +++ b/crates/denoise-core/src/bayer.rs @@ -14,6 +14,14 @@ pub struct Layout { pub dy: usize, } +/// The mosaic dimensions and cell origin for one square denoiser input tile. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TileRegion { + pub mosaic_size: (usize, usize), + pub cell_origin: (isize, isize), + pub tile_size: usize, +} + impl Layout { /// The layout of a 2 × 2 colour pattern, row-major, colours 0 = red, 1 = green, 2 = blue; `None` when /// it is not a Bayer cell (one red, one blue, the greens on the other diagonal). @@ -65,7 +73,10 @@ pub fn reflect(i: isize, n: usize) -> usize { /// Pack the `tile × tile` cells whose top-left cell is `(cx0, cy0)` into `out` as four planes `[R, G1, G2, B]` /// (`4 · tile²` values, NCHW). G1 is the green beside the red sample, G2 the green under it. Samples outside /// the picture are mirrored. Returns `false` (and writes nothing) when the sizes do not fit. -pub fn pack_tile(mosaic: &[f32], width: usize, height: usize, layout: Layout, cx0: isize, cy0: isize, tile: usize, out: &mut [f32]) -> bool { +pub fn pack_tile(mosaic: &[f32], layout: Layout, region: TileRegion, out: &mut [f32]) -> bool { + let (width, height) = region.mosaic_size; + let (cx0, cy0) = region.cell_origin; + let tile = region.tile_size; let Some(plane) = tile.checked_mul(tile) else { return false }; let Some(values) = plane.checked_mul(4) else { return false }; if width == 0 @@ -113,7 +124,7 @@ mod tests { let layout = Layout::from_cell([0, 1, 1, 2]).unwrap(); let mut out = [7.0; 4]; for (w, h, tile, x) in [(usize::MAX, 2, 1, 0), (1, 1, usize::MAX, 0), (1, 1, 0, 0), (1, 1, 1, isize::MAX), (1, 1, 1, isize::MIN)] { - assert!(!pack_tile(&[0.5], w, h, layout, x, 0, tile, &mut out)); + assert!(!pack_tile(&[0.5], layout, TileRegion { mosaic_size: (w, h), cell_origin: (x, 0), tile_size: tile }, &mut out)); assert_eq!(out, [7.0; 4]); } for n in [1, 2, isize::MAX as usize, usize::MAX] { @@ -160,7 +171,7 @@ mod tests { let (nx, ny) = layout.cells(w, h); let tile = nx.max(ny); let mut out = vec![0.0; 4 * tile * tile]; - assert!(pack_tile(&mosaic, w, h, layout, 0, 0, tile, &mut out)); + assert!(pack_tile(&mosaic, layout, TileRegion { mosaic_size: (w, h), cell_origin: (0, 0), tile_size: tile }, &mut out)); let plane = tile * tile; for (p, want) in [10.0, 20.0, 20.0, 30.0].into_iter().enumerate() { assert!(out[p * plane..(p + 1) * plane].iter().all(|&v| v == want), "layout {cell:?} plane {p}"); @@ -174,7 +185,7 @@ mod tests { let (w, h) = (4, 4); let mosaic: Vec = (0..16).map(|i| i as f32).collect(); let mut out = vec![0.0; 4 * 4]; - assert!(pack_tile(&mosaic, w, h, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out)); + assert!(pack_tile(&mosaic, Layout { dx: 0, dy: 0 }, TileRegion { mosaic_size: (w, h), cell_origin: (0, 0), tile_size: 2 }, &mut out)); // cell (0,0): R = site (0,0)=0, G1 = site (1,0)=1, G2 = site (0,1)=4, B = site (1,1)=5 assert_eq!([out[0], out[4], out[8], out[12]], [0.0, 1.0, 4.0, 5.0]); // cell (1,0): sites (2,0)=2, (3,0)=3, (2,1)=6, (3,1)=7 @@ -184,8 +195,8 @@ mod tests { #[test] fn packing_rejects_wrong_sizes() { let mut out = vec![0.0; 16]; - assert!(!pack_tile(&[0.0; 15], 4, 4, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out)); - assert!(!pack_tile(&[0.0; 16], 4, 4, Layout { dx: 0, dy: 0 }, 0, 0, 3, &mut out)); - assert!(!pack_tile(&[], 0, 0, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out)); + assert!(!pack_tile(&[0.0; 15], Layout { dx: 0, dy: 0 }, TileRegion { mosaic_size: (4, 4), cell_origin: (0, 0), tile_size: 2 }, &mut out)); + assert!(!pack_tile(&[0.0; 16], Layout { dx: 0, dy: 0 }, TileRegion { mosaic_size: (4, 4), cell_origin: (0, 0), tile_size: 3 }, &mut out)); + assert!(!pack_tile(&[], Layout { dx: 0, dy: 0 }, TileRegion { mosaic_size: (0, 0), cell_origin: (0, 0), tile_size: 2 }, &mut out)); } } diff --git a/crates/denoise-core/src/run.rs b/crates/denoise-core/src/run.rs index 4314ed8c3..180587d5d 100644 --- a/crates/denoise-core/src/run.rs +++ b/crates/denoise-core/src/run.rs @@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use lightcraft_raster::Rgb32f; use rayon::prelude::*; -use crate::bayer::{Layout, pack_tile}; +use crate::bayer::{Layout, TileRegion, pack_tile}; use crate::manifest::Gain; use crate::tiles::{starts, weight}; @@ -139,7 +139,12 @@ pub fn denoise_bayer( .par_iter() .map(|&(x0, y0, bx, ax, by, ay)| { let mut input = allocate(4 * p.tile * p.tile, 0f32)?; - if !pack_tile(mosaic, width, height, layout, x0 as isize, y0 as isize, p.tile, &mut input) { + if !pack_tile( + mosaic, + layout, + TileRegion { mosaic_size: (width, height), cell_origin: (x0 as isize, y0 as isize), tile_size: p.tile }, + &mut input, + ) { return Err(Error::Input("a tile could not be packed".into())); } let mut out = runner.run(&input)?; From 694b237f0a47337d722530893bee81f057f9ecb8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:19:12 +0000 Subject: [PATCH 11/39] refactor(faces): model crop and plane shift inputs --- crates/engine/src/faces_worker.rs | 14 ++++++-------- crates/faces/src/align.rs | 27 +++++++++++++++++++-------- crates/faces/src/net/kernels.rs | 20 ++++++++++++-------- crates/faces/src/net/program.rs | 2 +- crates/faces/src/net/shape.rs | 2 +- 5 files changed, 39 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/faces_worker.rs b/crates/engine/src/faces_worker.rs index 61dbc2544..c9512f6cb 100644 --- a/crates/engine/src/faces_worker.rs +++ b/crates/engine/src/faces_worker.rs @@ -13,7 +13,7 @@ use std::sync::{Arc, Condvar, Mutex, PoisonError}; use std::time::Duration; use lightcraft_catalog::PhotoId; -use lightcraft_faces::align::{Rgb, align_to_template, crop_box}; +use lightcraft_faces::align::{FaceCrop, Rgb, align_to_template, crop_box}; use lightcraft_faces::runtime::Embedder; use lightcraft_faces::yunet::{Detector, Face, Options}; use lightcraft_geom::Rect; @@ -221,13 +221,11 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done { Some((_, f)) => align_to_template(&img, &scaled(f), iw, ih), None => crop_box( &img, - rect.x0 as f32 * w as f32, - rect.y0 as f32 * h as f32, - rect.x1 as f32 * w as f32, - rect.y1 as f32 * h as f32, - BOX_PAD, - iw, - ih, + FaceCrop { + bounds: (rect.x0 as f32 * w as f32, rect.y0 as f32 * h as f32, rect.x1 as f32 * w as f32, rect.y1 as f32 * h as f32), + padding: BOX_PAD, + output_size: (iw, ih), + }, ), }; // shown by the detector's box where it found this face: every face then has the same tightness diff --git a/crates/faces/src/align.rs b/crates/faces/src/align.rs index 4982325ae..387d028a6 100644 --- a/crates/faces/src/align.rs +++ b/crates/faces/src/align.rs @@ -19,6 +19,14 @@ pub struct Rgb<'a> { pub height: usize, } +/// Face bounds, padding factor, and output dimensions for box-based alignment. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FaceCrop { + pub bounds: (f32, f32, f32, f32), + pub padding: f32, + pub output_size: (usize, usize), +} + impl Rgb<'_> { fn valid(&self) -> bool { self.width > 0 && self.height > 0 && self.width.checked_mul(self.height).and_then(|n| n.checked_mul(3)) == Some(self.data.len()) @@ -145,7 +153,10 @@ pub fn align_to_template(img: &Rgb, landmarks: &[(f32, f32); 5], out_w: usize, o } /// A square around a face box (pixels of `img`), `pad` times its larger side, as `out_w` × `out_h`. -pub fn crop_box(img: &Rgb, x0: f32, y0: f32, x1: f32, y1: f32, pad: f32, out_w: usize, out_h: usize) -> Option> { +pub fn crop_box(img: &Rgb, crop: FaceCrop) -> Option> { + let (x0, y0, x1, y1) = crop.bounds; + let pad = crop.padding; + let (out_w, out_h) = crop.output_size; if ![x0, y0, x1, y1, pad].iter().all(|v| v.is_finite()) { return None; } @@ -247,16 +258,16 @@ mod tests { } } let img = Rgb { data: &data, width: w, height: h }; - let out = crop_box(&img, 90.0, 40.0, 110.0, 60.0, 2.0, 64, 64).unwrap(); + let out = crop_box(&img, FaceCrop { bounds: (90.0, 40.0, 110.0, 60.0), padding: 2.0, output_size: (64, 64) }).unwrap(); // the 20 px block fills the middle half of the 64 px output let at = |x: usize, y: usize| out[(y * 64 + x) * 3 + 1]; assert!(at(32, 32) > 250 && at(20, 20) > 250 && at(44, 44) > 250 && at(5, 5) < 5 && at(60, 32) < 5); - assert!(crop_box(&img, 5.0, 5.0, 5.0, 5.0, 2.0, 64, 64).is_none(), "an empty box"); - assert!(crop_box(&img, f32::NAN, 0.0, 10.0, 10.0, 2.0, 64, 64).is_none()); - assert!(crop_box(&img, 0.0, 0.0, 10.0, 10.0, 2.0, 0, 64).is_none()); - assert!(crop_box(&img, 0.0, 0.0, 10.0, 10.0, 2.0, 100_000, 64).is_none()); + assert!(crop_box(&img, FaceCrop { bounds: (5.0, 5.0, 5.0, 5.0), padding: 2.0, output_size: (64, 64) }).is_none(), "an empty box"); + assert!(crop_box(&img, FaceCrop { bounds: (f32::NAN, 0.0, 10.0, 10.0), padding: 2.0, output_size: (64, 64) }).is_none()); + assert!(crop_box(&img, FaceCrop { bounds: (0.0, 0.0, 10.0, 10.0), padding: 2.0, output_size: (0, 64) }).is_none()); + assert!(crop_box(&img, FaceCrop { bounds: (0.0, 0.0, 10.0, 10.0), padding: 2.0, output_size: (100_000, 64) }).is_none()); let bad = Rgb { data: &data[..10], width: w, height: h }; - assert!(crop_box(&bad, 0.0, 0.0, 10.0, 10.0, 2.0, 64, 64).is_none(), "wrong data length"); + assert!(crop_box(&bad, FaceCrop { bounds: (0.0, 0.0, 10.0, 10.0), padding: 2.0, output_size: (64, 64) }).is_none(), "wrong data length"); } #[test] @@ -264,7 +275,7 @@ mod tests { let data = vec![80u8; 50 * 50 * 3]; let img = Rgb { data: &data, width: 50, height: 50 }; // a box mostly outside the picture is padded by clamping, not an error - assert!(crop_box(&img, -30.0, -30.0, 20.0, 20.0, 1.5, 32, 32).is_some()); + assert!(crop_box(&img, FaceCrop { bounds: (-30.0, -30.0, 20.0, 20.0), padding: 1.5, output_size: (32, 32) }).is_some()); let far: [(f32, f32); 5] = [(1e6, 1e6), (1e6 + 5.0, 1e6), (1e6 + 2.0, 1e6 + 3.0), (1e6, 1e6 + 6.0), (1e6 + 5.0, 1e6 + 6.0)]; let _ = align_to_template(&img, &far, 112, 112); let tiny: [(f32, f32); 5] = [(1.0, 1.0), (1.01, 1.0), (1.0, 1.01), (1.01, 1.01), (1.005, 1.005)]; diff --git a/crates/faces/src/net/kernels.rs b/crates/faces/src/net/kernels.rs index 71ae95874..6ad7e83dd 100644 --- a/crates/faces/src/net/kernels.rs +++ b/crates/faces/src/net/kernels.rs @@ -1,5 +1,5 @@ //! Safe CPU kernels shared by detection and recognition. No inner thread pool. -use super::*; +use super::{ConvJob, Graph, MAX_TENSOR, NetError, Node, Result, Tensor, chw, conv_direct, pads, pair, program, shape, shape_err}; use ndarray::{ArrayView2, ArrayViewMut2, linalg::general_mat_mul}; const BLOCK: usize = 256; #[derive(Default)] @@ -30,7 +30,16 @@ fn multiply(w: &[f32], x: &[f32], y: &mut [f32], m: usize, k: usize, n: usize) - } /// For equal-width, unit-stride maps a shifted plane is one contiguous copy. Only horizontal /// border stripes need masking; clipping the flat source range also removes vertical padding. -fn shifted_plane(plane: &[f32], width: usize, ky: usize, kx: usize, pt: usize, pl: usize, start: usize, row: &mut [f32]) -> Result<()> { +#[derive(Clone, Copy)] +struct PlaneShift { + width: usize, + kernel: (usize, usize), + padding: (usize, usize), + start: usize, +} + +fn shifted_plane(plane: &[f32], row: &mut [f32], shift: PlaneShift) -> Result<()> { + let PlaneShift { width, kernel: (ky, kx), padding: (pt, pl), start } = shift; let end = start + row.len(); let delta = (ky as i128 - pt as i128) * width as i128 + kx as i128 - pl as i128; let lo = (-delta).clamp(start as i128, end as i128); @@ -145,13 +154,8 @@ pub(super) fn conv(n: &Node, x: &Tensor, g: &Graph, scratch: &mut Scratch) -> Re if sh == 1 && sw == 1 && ow == w { shifted_plane( plane, - w, - ky, - kx, - pt, - pl, - start, row.get_mut(..len).ok_or_else(|| NetError::Shape("short lower panel".into()))?, + PlaneShift { width: w, kernel: (ky, kx), padding: (pt, pl), start }, )?; continue; } diff --git a/crates/faces/src/net/program.rs b/crates/faces/src/net/program.rs index e00e51da1..db7dcba5d 100644 --- a/crates/faces/src/net/program.rs +++ b/crates/faces/src/net/program.rs @@ -9,7 +9,7 @@ use std::borrow::Cow; -use super::*; +use super::{ConvJob, Data, HashMap, Net, NetError, Result, kernels, pads, pair, shape, shape_err, unsupported}; /// Value 0 is the network's input. pub const INPUT: usize = 0; diff --git a/crates/faces/src/net/shape.rs b/crates/faces/src/net/shape.rs index a1594ca6b..39e585c61 100644 --- a/crates/faces/src/net/shape.rs +++ b/crates/faces/src/net/shape.rs @@ -1,5 +1,5 @@ //! Bounded, allocation-free (apart from small shape lists) graph checking. -use super::*; +use super::{Data, Graph, HashMap, MAX_TENSOR, NetError, Node, Result, pads, pair, shape_err, unsupported}; use crate::graph::{Attr, Weight}; pub(super) fn elements(s: &[usize]) -> Result { From de6cb3cd4120b274dee48e6688b9a5460d8ca2de Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:19:14 +0000 Subject: [PATCH 12/39] refactor(codecs): separate TIFF sample encoders --- crates/codecs/src/encode.rs | 126 +++++++++++++++++++++++------------- 1 file changed, 82 insertions(+), 44 deletions(-) diff --git a/crates/codecs/src/encode.rs b/crates/codecs/src/encode.rs index 725980e53..8aff31c5c 100644 --- a/crates/codecs/src/encode.rs +++ b/crates/codecs/src/encode.rs @@ -227,7 +227,7 @@ pub enum TiffCompression { /// Encode TIFF: 8/16-bit integer or 32-bit float, gray/RGB with optional (unassociated) alpha. /// ICC and XMP are embedded; EXIF is not (write it through the TIFF writer in `lightcraft-tiff`). pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &EncodeMeta) -> Result> { - use tiff::encoder::{Compression, DeflateLevel, TiffEncoder, colortype}; + use tiff::encoder::{Compression, DeflateLevel, TiffEncoder}; use tiff::tags::Predictor; img.validate()?; let e = |e: tiff::TiffError| Error::Encode(e.to_string()); @@ -243,54 +243,92 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod if !float && matches!(compression, TiffCompression::Lzw | TiffCompression::Deflate) { Predictor::Horizontal } else { Predictor::None }; let mut enc = TiffEncoder::new(&mut cur).map_err(e)?.with_compression(comp).with_predictor(predictor); let (w, h) = (img.width, img.height); - let n = w as usize * h as usize; - // Gray + alpha is stored as RGBA (the encoder has no GrayA colour type). - let ch = img.channels as usize; - - macro_rules! write { - ($ct:ty, $data:expr, $alpha:expr) => {{ - let mut im = enc.new_image::<$ct>(w, h).map_err(e)?; - if let Some(ppi) = meta.ppi.filter(|p| *p > 0) { - im.resolution(tiff::tags::ResolutionUnit::Inch, tiff::encoder::Rational { n: ppi as u32, d: 1 }); - } - if let Some(icc) = meta.icc { - im.encoder().write_tag(tiff::tags::Tag::IccProfile, icc).map_err(e)?; - } - if let Some(xmp) = meta.xmp { - im.encoder().write_tag(tiff::tags::Tag::from_u16_exhaustive(700), xmp.as_bytes()).map_err(e)?; - } - if $alpha { - im.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[2u16][..]).map_err(e)?; - } - im.write_data($data).map_err(e)?; - }}; - } - fn expand_ga(s: &[T], n: usize) -> Vec { - s.as_chunks::<2>().0.iter().take(n).flat_map(|c| [c[0], c[0], c[0], c[1]]).collect() - } + let pixels = w as usize * h as usize; match img.samples { - Samples::U8(s) => match ch { - 1 => write!(colortype::Gray8, &s[..n], false), - 2 => write!(colortype::RGBA8, &expand_ga(s, n), true), - 3 => write!(colortype::RGB8, &s[..n * 3], false), - _ => write!(colortype::RGBA8, &s[..n * 4], true), - }, - Samples::U16(s) => match ch { - 1 => write!(colortype::Gray16, &s[..n], false), - 2 => write!(colortype::RGBA16, &expand_ga(s, n), true), - 3 => write!(colortype::RGB16, &s[..n * 3], false), - _ => write!(colortype::RGBA16, &s[..n * 4], true), - }, - Samples::F32(s) => match ch { - 1 => write!(colortype::Gray32Float, &s[..n], false), - 2 => write!(colortype::RGBA32Float, &expand_ga(s, n), true), - 3 => write!(colortype::RGB32Float, &s[..n * 3], false), - _ => write!(colortype::RGBA32Float, &s[..n * 4], true), - }, + Samples::U8(samples) => write_tiff_u8(&mut enc, samples, pixels, img.channels as usize, img, meta)?, + Samples::U16(samples) => write_tiff_u16(&mut enc, samples, pixels, img.channels as usize, img, meta)?, + Samples::F32(samples) => write_tiff_f32(&mut enc, samples, pixels, img.channels as usize, img, meta)?, } Ok(cur.into_inner()) } +macro_rules! write_tiff_image { + ($encoder:expr, $image:expr, $meta:expr, $color:ty, $data:expr, $alpha:expr) => {{ + let mut image = $encoder.new_image::<$color>($image.width, $image.height).map_err(|e| Error::Encode(e.to_string()))?; + if let Some(ppi) = $meta.ppi.filter(|ppi| *ppi > 0) { + image.resolution(tiff::tags::ResolutionUnit::Inch, tiff::encoder::Rational { n: ppi as u32, d: 1 }); + } + if let Some(icc) = $meta.icc { + image.encoder().write_tag(tiff::tags::Tag::IccProfile, icc).map_err(|e| Error::Encode(e.to_string()))?; + } + if let Some(xmp) = $meta.xmp { + image.encoder().write_tag(tiff::tags::Tag::from_u16_exhaustive(700), xmp.as_bytes()).map_err(|e| Error::Encode(e.to_string()))?; + } + if $alpha { + image.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[2u16][..]).map_err(|e| Error::Encode(e.to_string()))?; + } + image.write_data($data).map_err(|e| Error::Encode(e.to_string()))?; + }}; +} + +fn expanded_gray_alpha(samples: &[T], pixels: usize) -> Vec { + samples.as_chunks::<2>().0.iter().take(pixels).flat_map(|channel| [channel[0], channel[0], channel[0], channel[1]]).collect() +} + +fn write_tiff_u8( + encoder: &mut tiff::encoder::TiffEncoder, + samples: &[u8], + pixels: usize, + channels: usize, + image: &EncodeImage, + meta: &EncodeMeta, +) -> Result<()> { + use tiff::encoder::colortype; + match channels { + 1 => write_tiff_image!(encoder, image, meta, colortype::Gray8, &samples[..pixels], false), + 2 => write_tiff_image!(encoder, image, meta, colortype::RGBA8, &expanded_gray_alpha(samples, pixels), true), + 3 => write_tiff_image!(encoder, image, meta, colortype::RGB8, &samples[..pixels * 3], false), + _ => write_tiff_image!(encoder, image, meta, colortype::RGBA8, &samples[..pixels * 4], true), + } + Ok(()) +} + +fn write_tiff_u16( + encoder: &mut tiff::encoder::TiffEncoder, + samples: &[u16], + pixels: usize, + channels: usize, + image: &EncodeImage, + meta: &EncodeMeta, +) -> Result<()> { + use tiff::encoder::colortype; + match channels { + 1 => write_tiff_image!(encoder, image, meta, colortype::Gray16, &samples[..pixels], false), + 2 => write_tiff_image!(encoder, image, meta, colortype::RGBA16, &expanded_gray_alpha(samples, pixels), true), + 3 => write_tiff_image!(encoder, image, meta, colortype::RGB16, &samples[..pixels * 3], false), + _ => write_tiff_image!(encoder, image, meta, colortype::RGBA16, &samples[..pixels * 4], true), + } + Ok(()) +} + +fn write_tiff_f32( + encoder: &mut tiff::encoder::TiffEncoder, + samples: &[f32], + pixels: usize, + channels: usize, + image: &EncodeImage, + meta: &EncodeMeta, +) -> Result<()> { + use tiff::encoder::colortype; + match channels { + 1 => write_tiff_image!(encoder, image, meta, colortype::Gray32Float, &samples[..pixels], false), + 2 => write_tiff_image!(encoder, image, meta, colortype::RGBA32Float, &expanded_gray_alpha(samples, pixels), true), + 3 => write_tiff_image!(encoder, image, meta, colortype::RGB32Float, &samples[..pixels * 3], false), + _ => write_tiff_image!(encoder, image, meta, colortype::RGBA32Float, &samples[..pixels * 4], true), + } + Ok(()) +} + /// Encode a flat PSD (8/16-bit gray or RGB, no layers; alpha dropped): the hand-off an image /// editor such as PhotoCraft saves back to in place. ICC, EXIF, XMP and resolution are embedded. pub fn encode_psd(img: &EncodeImage, meta: &EncodeMeta) -> Result> { From 33252920e3f15a576d81aa6f49beb86630af6c69 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:19:16 +0000 Subject: [PATCH 13/39] refactor(denoise): model installation requests --- crates/engine/src/cmd/denoise.rs | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/denoise.rs b/crates/engine/src/cmd/denoise.rs index 644d3b89e..1cb99ccf3 100644 --- a/crates/engine/src/cmd/denoise.rs +++ b/crates/engine/src/cmd/denoise.rs @@ -129,16 +129,18 @@ fn identify(path: &Path, c: &str, sha_known: Option<&str>) -> Result { /// Install the model in `src`. A model that passes its self-test becomes the one in use (`activate`, the default): installing /// a model is how someone says they want it. `verified` is for a download whose hash was already checked. -fn install_file( - dir: &Path, - loader: &crate::denoise::Loader, +struct InstallFile<'a> { + dir: &'a Path, run_on: RunOn, accepted_at: String, - c: &str, - src: &Path, + command: &'a str, + src: &'a Path, acknowledged: bool, verified: bool, -) -> Result<(Value, Arc)> { +} + +fn install_file(loader: &crate::denoise::Loader, request: InstallFile<'_>) -> Result<(Value, Arc)> { + let InstallFile { dir, run_on, accepted_at, command: c, src, acknowledged, verified } = request; let ident = identify(src, c, None)?; let m = ident.manifest.clone(); manifest::validate(&m).map_err(|e| bad(c, e.to_string()))?; @@ -262,7 +264,8 @@ fn start_install(s: &mut Session, src: PathBuf, id: String, activate: bool, veri .spawn(move || { let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let _held = crate::memory::work_gate().acquire(512 * 1024 * 1024); - install_file(&dir, &loader, run_on, at, C, &src, true, verified).map_err(|e| plain(&e)) + install_file(&loader, InstallFile { dir: &dir, run_on, accepted_at: at, command: C, src: &src, acknowledged: true, verified }) + .map_err(|e| plain(&e)) })) .unwrap_or_else(|_| Err("the model installation stopped unexpectedly".into())); if verified { @@ -290,8 +293,18 @@ fn install(s: &mut Session, p: &Value) -> Result { if s.denoise.install.is_some() { return Err(bad(C, "another model is being installed")); } - let (result, model) = - install_file(&models_dir(s, C)?, &s.denoise.loader, s.denoise.settings.run_on(), (s.clock)(), C, Path::new(path), acknowledged, false)?; + let (result, model) = install_file( + &s.denoise.loader, + InstallFile { + dir: &models_dir(s, C)?, + run_on: s.denoise.settings.run_on(), + accepted_at: (s.clock)(), + command: C, + src: Path::new(path), + acknowledged, + verified: false, + }, + )?; activate_install(s, &result, model, activate) } From e8f52cc07e1a7520b9365c26288e68a556d75465 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:19:18 +0000 Subject: [PATCH 14/39] refactor(print): define blit rectangles --- crates/engine/src/print/compose.rs | 19 +++++++++++++++---- crates/engine/src/print/compose/tests.rs | 4 ++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/print/compose.rs b/crates/engine/src/print/compose.rs index d8e1eeb87..16c683a84 100644 --- a/crates/engine/src/print/compose.rs +++ b/crates/engine/src/print/compose.rs @@ -255,6 +255,15 @@ pub struct Image16 { pub rgb: Vec, } +/// A rectangular image region used as a source or destination for `Image16::blit`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BlitRect { + pub x: usize, + pub y: usize, + pub w: usize, + pub h: usize, +} + impl Image16 { pub fn filled(w: usize, h: usize, c: [u16; 3]) -> Result { let n = w.checked_mul(h).and_then(|n| n.checked_mul(3)).ok_or("image too large")?; @@ -284,9 +293,11 @@ impl Image16 { } /// Copies the `w` x `h` block of `src` at (`sx`, `sy`) to (`dx`, `dy`), clipped to both images. - pub fn blit(&mut self, src: &Image16, sx: usize, sy: usize, dx: usize, dy: usize, w: usize, h: usize) { - let w = w.min(src.w.saturating_sub(sx)).min(self.w.saturating_sub(dx)); - let h = h.min(src.h.saturating_sub(sy)).min(self.h.saturating_sub(dy)); + pub fn blit(&mut self, src: &Image16, source: BlitRect, destination: BlitRect) { + let BlitRect { x: sx, y: sy, .. } = source; + let BlitRect { x: dx, y: dy, .. } = destination; + let w = source.w.min(destination.w).min(src.w.saturating_sub(sx)).min(self.w.saturating_sub(dx)); + let h = source.h.min(destination.h).min(src.h.saturating_sub(sy)).min(self.h.saturating_sub(dy)); for row in 0..h { let from = src.rgb.get(((sy + row) * src.w + sx) * 3..((sy + row) * src.w + sx + w) * 3); let to = self.rgb.get_mut(((dy + row) * self.w + dx) * 3..((dy + row) * self.w + dx + w) * 3); @@ -424,7 +435,7 @@ pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &Cell if border > 0 { page.fill_rect(dx - border, dy - border, w + 2 * border, h + 2 * border, style.border); } - page.blit(&img, sx, sy, dx, dy, w, h); + page.blit(&img, BlitRect { x: sx, y: sy, w, h }, BlitRect { x: dx, y: dy, w, h }); let s = style.stroke_px.min(w.min(h) / 2); if s > 0 { page.fill_rect(dx, dy, w, s, style.stroke); diff --git a/crates/engine/src/print/compose/tests.rs b/crates/engine/src/print/compose/tests.rs index cdec92799..f1e979cc0 100644 --- a/crates/engine/src/print/compose/tests.rs +++ b/crates/engine/src/print/compose/tests.rs @@ -182,9 +182,9 @@ fn rotate_and_blit_are_clipped() { let r = img.rotated_cw().unwrap(); assert_eq!((r.w, r.h), (2, 3)); let mut p = page(); - p.blit(&img, 0, 0, 99, 99, 10, 10); + p.blit(&img, BlitRect { x: 0, y: 0, w: 10, h: 10 }, BlitRect { x: 99, y: 99, w: 10, h: 10 }); assert_eq!(at(&p, 99, 99), at(&img, 0, 0)); - p.blit(&img, 5, 5, 0, 0, 3, 3); + p.blit(&img, BlitRect { x: 5, y: 5, w: 3, h: 3 }, BlitRect { x: 0, y: 0, w: 3, h: 3 }); p.fill_rect(95, 95, 50, 50, RED); assert_eq!(at(&p, 99, 99), RED); p.fill_rect(usize::MAX, usize::MAX, usize::MAX, usize::MAX, RED); From 9e1483388f93fc5590c59b198183c36b58b3bab9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:19:20 +0000 Subject: [PATCH 15/39] refactor(import): separate Lightroom collection staging --- crates/engine/src/lightroom_catalog.rs | 146 +++++++++++++------------ 1 file changed, 79 insertions(+), 67 deletions(-) diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 207d1c148..3066187ce 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -858,6 +858,83 @@ pub(crate) struct ApplyPrepared<'a> { pub(crate) undo0: usize, } +fn stage_collections( + s: &mut crate::Session, + data: &CatalogImport, + index: &mut ImportIndex, + ids: &HashMap, + ops: &mut Vec, + warnings: &mut Vec, +) -> usize { + let mut albums = HashMap::new(); + for collection in &data.collections { + if number(collection, "systemOnly") != 0 { + continue; + } + let collection_key = format!("{}:{}", key(&data.source), number(collection, "id_local")); + let id = index + .collections + .get(&collection_key) + .copied() + .map(lightcraft_catalog::AlbumId) + .filter(|id| s.catalog.album(*id).is_some()) + .unwrap_or_else(|| s.catalog.alloc_album_id()); + index.collections.insert(collection_key, id.0); + albums.insert(number(collection, "id_local"), id); + } + let parents: HashMap<_, _> = data.collections.iter().map(|r| (number(r, "id_local"), number(r, "parent"))).collect(); + let mut grouped_members: HashMap> = HashMap::new(); + for member in &data.members { + grouped_members.entry(number(member, "collection")).or_default().push(member); + } + for members in grouped_members.values_mut() { + members.sort_by(|a, b| text(a, "positionInCollection").cmp(text(b, "positionInCollection"))); + } + let mut ordered: Vec<_> = data.collections.iter().collect(); + ordered.sort_by_key(|r| { + let mut depth = 0; + let mut at = number(r, "parent"); + while at != 0 && depth < 64 { + depth += 1; + at = parents.get(&at).copied().unwrap_or(0); + } + depth + }); + for collection in ordered { + let Some(id) = albums.get(&number(collection, "id_local")).copied() else { continue }; + let mut album = Album::new(id, text(collection, "name")); + album.parent = albums.get(&number(collection, "parent")).copied(); + album.folder = text(collection, "creationId").contains("group"); + if text(collection, "creationId").contains("smart") { + warnings.push(format!("{}: smart rules preserved in archive; imported current membership", album.name)); + } + if !album.folder { + album.photos = grouped_members + .get(&number(collection, "id_local")) + .into_iter() + .flatten() + .filter_map(|r| ids.get(&number(r, "image")).copied()) + .collect(); + } + if s.catalog.album(id).is_some() { + // Reimport preserves the user's album name/hierarchy, merges only new membership. + if let Some(old) = s.catalog.album(id).filter(|a| !a.folder && !a.is_smart()) { + let mut photos = old.photos.clone(); + let mut seen: HashSet<_> = photos.iter().copied().collect(); + for photo in album.photos { + if seen.insert(photo) { + photos.push(photo); + } + } + ops.push(Op::SetAlbumPhotos { id, photos }); + } + } else { + ops.push(Op::AddAlbum { album }); + } + } + albums.len() +} + /// Turn the read catalog into library records in one undo step and return the import report /// (added, missing, failed, warnings and skipped images). pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result { @@ -1022,78 +1099,13 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co } by_path.entry(key(&src.path)).or_insert(photo_id); } - let mut albums = HashMap::new(); - for collection in &data.collections { - if number(collection, "systemOnly") != 0 { - continue; - } - let collection_key = format!("{}:{}", key(&data.source), number(collection, "id_local")); - let id = index - .collections - .get(&collection_key) - .copied() - .map(lightcraft_catalog::AlbumId) - .filter(|id| s.catalog.album(*id).is_some()) - .unwrap_or_else(|| s.catalog.alloc_album_id()); - index.collections.insert(collection_key, id.0); - albums.insert(number(collection, "id_local"), id); - } - let parents: HashMap<_, _> = data.collections.iter().map(|r| (number(r, "id_local"), number(r, "parent"))).collect(); - let mut grouped_members: HashMap> = HashMap::new(); - for member in &data.members { - grouped_members.entry(number(member, "collection")).or_default().push(member); - } - for members in grouped_members.values_mut() { - members.sort_by(|a, b| text(a, "positionInCollection").cmp(text(b, "positionInCollection"))); - } - let mut ordered: Vec<_> = data.collections.iter().collect(); - ordered.sort_by_key(|r| { - let mut depth = 0; - let mut at = number(r, "parent"); - while at != 0 && depth < 64 { - depth += 1; - at = parents.get(&at).copied().unwrap_or(0); - } - depth - }); - for collection in ordered { - let Some(id) = albums.get(&number(collection, "id_local")).copied() else { continue }; - let mut album = Album::new(id, text(collection, "name")); - album.parent = albums.get(&number(collection, "parent")).copied(); - album.folder = text(collection, "creationId").contains("group"); - if text(collection, "creationId").contains("smart") { - warnings.push(format!("{}: smart rules preserved in archive; imported current membership", album.name)); - } - if !album.folder { - album.photos = grouped_members - .get(&number(collection, "id_local")) - .into_iter() - .flatten() - .filter_map(|r| ids.get(&number(r, "image")).copied()) - .collect(); - } - if s.catalog.album(id).is_some() { - // Reimport preserves the user's album name/hierarchy, merges only new membership. - if let Some(old) = s.catalog.album(id).filter(|a| !a.folder && !a.is_smart()) { - let mut photos = old.photos.clone(); - let mut seen: HashSet<_> = photos.iter().copied().collect(); - for photo in album.photos { - if seen.insert(photo) { - photos.push(photo); - } - } - ops.push(Op::SetAlbumPhotos { id, photos }); - } - } else { - ops.push(Op::AddAlbum { album }); - } - } + let album_count = stage_collections(s, &data, index, &ids, &mut ops, &mut warnings); if !ops.is_empty() { s.commit("Import Lightroom Catalog", Op::Batch { ops })?; } s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog"); Ok(AppliedImport { - report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"skipped":data.skipped,"unmapped":unmapped,"archive":archive_path,"mapping":ids}), + report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":album_count,"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"skipped":data.skipped,"unmapped":unmapped,"archive":archive_path,"mapping":ids}), index: index.clone(), }) } From 9c1ea73116551d5ff85f9afe925b7ce258883b5c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:19:23 +0000 Subject: [PATCH 16/39] refactor(gpu): extract neural plan validation --- crates/gpu/src/nn.rs | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/crates/gpu/src/nn.rs b/crates/gpu/src/nn.rs index 55420598b..c5df00a77 100644 --- a/crates/gpu/src/nn.rs +++ b/crates/gpu/src/nn.rs @@ -235,32 +235,35 @@ fn round_up(v: usize, to: usize) -> usize { v.div_ceil(to) * to } -/// Work out the steps, the buffers and the arranged weights for `net` on `tile × tile` input cells. -fn plan(net: &Net, tile: usize, max_binding: u64) -> Result<(Layout, Vec), String> { +fn validate_plan(net: &Net, tile: usize, ops: &[Op]) -> Result<(), String> { if !net.fits_tile(tile) { return unsupported("the tile does not fit the network"); } if tile > MAX_TILE { return unsupported("the tile is larger than the kernels' dispatches reach"); } + if !net.in_channels.is_multiple_of(4) { + return unsupported("the input channel count is not a multiple of 4"); + } + let count = ops.len(); + let ends_right = matches!( + (count.checked_sub(2).and_then(|i| ops.get(i)), ops.last()), + (Some(Op::Conv(conv)), Some(Op::DepthToSpace2 { src })) + if conv.k == 1 && conv.src2.is_none() && *src == count - 1 && conv.cout == 12 && conv.leaky.is_none() + ); + if ends_right { Ok(()) } else { unsupported("the network does not end in a 1 × 1 convolution to 12 channels and depth-to-space") } +} + +/// Work out the steps, the buffers and the arranged weights for `net` on `tile × tile` input cells. +fn plan(net: &Net, tile: usize, max_binding: u64) -> Result<(Layout, Vec), String> { + let ops = net.ops(); + validate_plan(net, tile, ops)?; let side = |t: usize| -> usize { let level = net.shape(t).map_or(0, |s| s.level); if level >= 0 { tile >> level } else { tile << (-level) } }; let chan = |t: usize| net.shape(t).map_or(0, |s| s.channels); - if !net.in_channels.is_multiple_of(4) { - return unsupported("the input channel count is not a multiple of 4"); - } - let ops = net.ops(); - // the network must end in a 1 × 1 convolution and a depth-to-space (they run as one kernel) let n_ops = ops.len(); - let ends_right = matches!( - (n_ops.checked_sub(2).and_then(|i| ops.get(i)), ops.last()), - (Some(Op::Conv(c)), Some(Op::DepthToSpace2 { src })) if c.k == 1 && c.src2.is_none() && *src == n_ops - 1 && c.cout == 12 && c.leaky.is_none() - ); - if !ends_right { - return unsupported("the network does not end in a 1 × 1 convolution to 12 channels and depth-to-space"); - } // the last layer that reads each tensor let mut last_use = vec![0usize; n_ops + 1]; for (i, op) in ops.iter().enumerate() { From 47839a61db4f21b11954d28639e1d64b93893039 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:19:26 +0000 Subject: [PATCH 17/39] refactor(bench): dispatch specialized render modes --- crates/engine/examples/render_bench.rs | 188 +++++++++++++------------ 1 file changed, 100 insertions(+), 88 deletions(-) diff --git a/crates/engine/examples/render_bench.rs b/crates/engine/examples/render_bench.rs index 3d7dbc445..54b6cfe82 100644 --- a/crates/engine/examples/render_bench.rs +++ b/crates/engine/examples/render_bench.rs @@ -91,107 +91,119 @@ fn diff(a: &lightcraft_raster::Rgba8, b: &lightcraft_raster::Rgba8) -> (u8, f64) (max, sum as f64 / (a.data.len() * 3).max(1) as f64) } +fn benchmark_jpeg(args: &[String], n: usize) { + // a JPEG (e.g. an embedded preview pulled out with exiftool) decoded to fit a few boxes + for path in args { + let bytes = std::fs::read(path).expect("read"); + for edge in [256u32, 512, 1600, 2560] { + let o = lightcraft_codecs::DecodeOptions::fit(edge, edge); + let d = lightcraft_codecs::decode(&bytes, o).expect("decode"); + println!( + " {path} fit {edge}: {}×{} {}", + d.image.width, + d.image.height, + best(n, || drop(lightcraft_codecs::decode(&bytes, o).expect("decode"))) + ); + } + } +} + +fn benchmark_load(args: &[String], n: usize, only: &str) { + // opening a photo in the loupe, step by step: what the app's loaders cost (per file) + for path in args { + let bytes = std::fs::read(path).expect("read"); + println!("{path}"); + if std::env::var("LOADONLY").is_ok() { + // only the preview load (for a profiler) + println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load")))); + continue; + } + println!(" {:<36} {}", "file read:", best(n, || drop(std::fs::read(path).expect("read")))); + println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode")))); + let mut raw = lightcraft_raw::decode(&bytes).expect("decode"); + if let lightcraft_raw::RawData::U16(d) = &raw.data { + let sum = d.iter().enumerate().fold(0u64, |a, (i, &v)| a.wrapping_mul(31).wrapping_add(v as u64 ^ i as u64)); + println!(" decode checksum: {sum:016x}"); + } + if only == "load1" { + println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load")))); + continue; + } + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) { + println!(" {:<36} {}", format!("binned ×{k} masked:"), best(n, || drop(raw.develop_binned_masked(k, 0.99)))); + } + for edge in [2560usize, 1600] { + println!( + " {:<36} {}", + format!("embedded srgb, whole file ({edge}):"), + best(n, || drop(lightcraft_engine::files::embedded_preview_srgb(&bytes, edge))) + ); + let quick = lightcraft_engine::files::fs_preview_loader(); + println!(" {:<36} {}", format!("fs_preview_loader ({edge}):"), best(n, || drop(quick(path, edge)))); + } + println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load")))); + let (loader, _) = lightcraft_engine::files::fs_hooks(); + println!(" {:<36} {}", "fs loader (2560, read+load):", best(n, || drop(loader(path, 2560).expect("load")))); + } +} + +fn benchmark_source(args: &[String], n: usize) { + // opening a photo: where the time goes before the first sharp loupe (per file) + for path in args { + let bytes = std::fs::read(path).expect("read"); + println!("{path}"); + let mut raw = lightcraft_raw::decode(&bytes).expect("decode"); + // as the loader does: lens corrections are applied by the pipeline, not in the raw develop + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + println!(" {}×{} cpp {} cfa {:?}", raw.width, raw.height, raw.cpp, raw.cfa.as_ref().map(|c| c.name())); + println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode")))); + println!(" {:<36} {}", "normalize:", best(n, || drop(raw.normalized().expect("norm")))); + let norm = raw.normalized().expect("norm"); + println!(" {:<36} {}", "demosaic AHD:", best(n, || drop(lightcraft_raw::demosaic(&norm, lightcraft_raw::Method::Ahd)))); + if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) { + println!(" {:<36} {}", format!("binned ×{k} (2560 preview):"), best(n, || drop(raw.develop_binned(k, 0.99)))); + let img = raw.develop_binned(k, 0.99).expect("bin").expect("binnable"); + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + println!( + " {:<36} {}", + "highlight reconstruct (binned):", + best(n, || { + let mut i = img.clone(); + lightcraft_raw::highlight::reconstruct(&mut i, t.wb, 0.99); + }) + ); + println!(" {:<36} {}", "clone (binned):", best(n, || drop(img.clone()))); + println!(" {:<36} {}", "fit 2560 (binned):", best(n, || drop(fit(&img, 2560, 2560, Filter::Box)))); + } + println!(" {:<36} {}", "embedded preview (2560):", best(n, || drop(lightcraft_engine::files::load_embedded_preview(&bytes, 2560)))); + for edge in [512usize, 2560, usize::MAX] { + println!( + " {:<36} {}", + format!("load_bytes({edge}):"), + best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, edge).expect("load"))) + ); + } + } +} + fn main() { let args: Vec = std::env::args().skip(1).collect(); let n: usize = std::env::var("N").ok().and_then(|v| v.parse().ok()).unwrap_or(5); let only = std::env::var("ONLY").unwrap_or_default(); - let run = |name: &str| only.is_empty() || only.split(',').any(|o| name.contains(o)); if only == "jpeg" { - // a JPEG (e.g. an embedded preview pulled out with exiftool) decoded to fit a few boxes - for path in &args { - let bytes = std::fs::read(path).expect("read"); - for edge in [256u32, 512, 1600, 2560] { - let o = lightcraft_codecs::DecodeOptions::fit(edge, edge); - let d = lightcraft_codecs::decode(&bytes, o).expect("decode"); - println!( - " {path} fit {edge}: {}×{} {}", - d.image.width, - d.image.height, - best(n, || drop(lightcraft_codecs::decode(&bytes, o).expect("decode"))) - ); - } - } + benchmark_jpeg(&args, n); return; } if only == "load" || only == "load1" { - // opening a photo in the loupe, step by step: what the app's loaders cost (per file) - for path in &args { - let bytes = std::fs::read(path).expect("read"); - println!("{path}"); - if std::env::var("LOADONLY").is_ok() { - // only the preview load (for a profiler) - println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load")))); - continue; - } - println!(" {:<36} {}", "file read:", best(n, || drop(std::fs::read(path).expect("read")))); - println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode")))); - let mut raw = lightcraft_raw::decode(&bytes).expect("decode"); - if let lightcraft_raw::RawData::U16(d) = &raw.data { - let sum = d.iter().enumerate().fold(0u64, |a, (i, &v)| a.wrapping_mul(31).wrapping_add(v as u64 ^ i as u64)); - println!(" decode checksum: {sum:016x}"); - } - if only == "load1" { - println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load")))); - continue; - } - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) { - println!(" {:<36} {}", format!("binned ×{k} masked:"), best(n, || drop(raw.develop_binned_masked(k, 0.99)))); - } - for edge in [2560usize, 1600] { - println!( - " {:<36} {}", - format!("embedded srgb, whole file ({edge}):"), - best(n, || drop(lightcraft_engine::files::embedded_preview_srgb(&bytes, edge))) - ); - let quick = lightcraft_engine::files::fs_preview_loader(); - println!(" {:<36} {}", format!("fs_preview_loader ({edge}):"), best(n, || drop(quick(path, edge)))); - } - println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load")))); - let (loader, _) = lightcraft_engine::files::fs_hooks(); - println!(" {:<36} {}", "fs loader (2560, read+load):", best(n, || drop(loader(path, 2560).expect("load")))); - } + benchmark_load(&args, n, &only); return; } if only == "source" { - // opening a photo: where the time goes before the first sharp loupe (per file) - for path in &args { - let bytes = std::fs::read(path).expect("read"); - println!("{path}"); - let mut raw = lightcraft_raw::decode(&bytes).expect("decode"); - // as the loader does: lens corrections are applied by the pipeline, not in the raw develop - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - println!(" {}×{} cpp {} cfa {:?}", raw.width, raw.height, raw.cpp, raw.cfa.as_ref().map(|c| c.name())); - println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode")))); - println!(" {:<36} {}", "normalize:", best(n, || drop(raw.normalized().expect("norm")))); - let norm = raw.normalized().expect("norm"); - println!(" {:<36} {}", "demosaic AHD:", best(n, || drop(lightcraft_raw::demosaic(&norm, lightcraft_raw::Method::Ahd)))); - if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) { - println!(" {:<36} {}", format!("binned ×{k} (2560 preview):"), best(n, || drop(raw.develop_binned(k, 0.99)))); - let img = raw.develop_binned(k, 0.99).expect("bin").expect("binnable"); - let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - println!( - " {:<36} {}", - "highlight reconstruct (binned):", - best(n, || { - let mut i = img.clone(); - lightcraft_raw::highlight::reconstruct(&mut i, t.wb, 0.99); - }) - ); - println!(" {:<36} {}", "clone (binned):", best(n, || drop(img.clone()))); - println!(" {:<36} {}", "fit 2560 (binned):", best(n, || drop(fit(&img, 2560, 2560, Filter::Box)))); - } - println!(" {:<36} {}", "embedded preview (2560):", best(n, || drop(lightcraft_engine::files::load_embedded_preview(&bytes, 2560)))); - for edge in [512usize, 2560, usize::MAX] { - println!( - " {:<36} {}", - format!("load_bytes({edge}):"), - best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, edge).expect("load"))) - ); - } - } + benchmark_source(&args, n); return; } + let run = |name: &str| only.is_empty() || only.split(',').any(|o| name.contains(o)); let (full, info) = match args.first() { Some(path) => { let bytes = std::fs::read(path).expect("read"); From 4d8369b09107fc7532140921a5083aebdedfe697 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:19:29 +0000 Subject: [PATCH 18/39] test(engine): factor complex regression scenarios --- crates/catalog/src/tests.rs | 43 ++-- crates/engine/src/crs/tests.rs | 18 +- crates/engine/src/export/tests.rs | 10 + .../src/sidecar/tests_repair_companions.rs | 146 ++++++------ .../src/sidecar/tests_repair_provenance.rs | 112 ++++----- .../engine/src/sidecar/tests_repair_saves.rs | 216 +++++++++--------- crates/engine/src/tests/import.rs | 198 +++++++++------- 7 files changed, 401 insertions(+), 342 deletions(-) diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 579175086..bf67b2495 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -904,17 +904,9 @@ fn search_text_finds_people_and_quoted_values() { /// What the catalog remembers of its latest ops: which photos each changed and what of them, as long as that can be /// said photo by photo. #[test] -fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() { +fn photo_ops_record_touched_ids_and_changed_aspects() { let mut c = Catalog::default(); - let photo = |c: &mut Catalog, name: &str| { - let id = c.alloc_photo_id(); - c.apply(Op::AddPhoto { - photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{name}") }, name, "JPEG", 60, 40, "2026-01-01T10:00:00")), - }) - .unwrap(); - id - }; - let (a, b) = (photo(&mut c, "a.jpg"), photo(&mut c, "b.jpg")); + let (a, b) = (photo(&mut c, "a.jpg", "2026-01-01T10:00:00"), photo(&mut c, "b.jpg", "2026-01-01T10:00:00")); let start = c.revision; assert_eq!(c.touched_since(start), Some(vec![])); assert_eq!(c.aspects_since(start), Some(Aspects::NONE)); @@ -940,28 +932,25 @@ fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() assert_eq!(c.touched_since(before), None); assert_eq!(c.aspects_since(start), None); assert_eq!(c.touched_since(c.revision), Some(vec![]), "from after it, it is known again"); - // a revision that is not this catalog's past - assert_eq!(c.touched_since(c.revision + 5), None); - // a revision set from outside (a library just loaded): nothing is known of how the catalog got there, and after - // the next op nothing of before it - let old = c.revision; +} + +#[test] +fn history_handles_loaded_revisions_failed_batches_and_retention() { + let mut c = Catalog::default(); + let a = photo(&mut c, "a.jpg", "2026-01-01T10:00:00"); + let album = c.alloc_album_id(); + c.apply(Op::AddAlbum { album: Album::new(album, "Trip") }).unwrap(); + // a batch that fails midway is undone as a whole; a batch in a batch is remembered photo by photo; a batch + // with an op that changes more than photos is not + let before = c.revision; c.revision = 7 << 32; - assert_eq!(c.touched_since(old), None); + assert_eq!(c.touched_since(before), None); assert_eq!(c.aspects_since(c.revision), None); let loaded = c.revision; c.apply(Op::SetRating { id: a, rating: 1 }).unwrap(); assert_eq!(c.touched_since(loaded), Some(vec![a])); - assert_eq!(c.touched_since(old), None); - // an op that fails changes nothing and is not remembered - let before = c.revision; - assert!(c.apply(Op::SetRating { id: PhotoId(9_999), rating: 1 }).is_err()); - assert_eq!(c.revision, before); - assert_eq!(c.touched_since(before), Some(vec![])); - c.apply(Op::SetFlag { id: a, flag: Flag::Pick }).unwrap(); - assert_eq!(c.touched_since(before), Some(vec![a])); - assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::FLAG))); - // a batch that fails midway is undone as a whole; a batch in a batch is remembered photo by photo; a batch - // with an op that changes more than photos is not + assert_eq!(c.touched_since(before), None); + let before = c.revision; assert!(c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 5 }, Op::SetRating { id: PhotoId(9_999), rating: 1 }] }).is_err()); assert_eq!((c.revision, c.photo(a).map(|p| p.rating)), (before, Some(1))); diff --git a/crates/engine/src/crs/tests.rs b/crates/engine/src/crs/tests.rs index 2779194c1..9f46cf401 100644 --- a/crates/engine/src/crs/tests.rs +++ b/crates/engine/src/crs/tests.rs @@ -37,9 +37,16 @@ fn props(x: &str) -> Props { #[test] fn maps_common_fields() { let p = props(SIDECAR); - assert!(has_adjustments(&p)); let partial = to_partial(&p, Target::RawAbsolute); let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0); + assert!(has_adjustments(&p)); + assert_common_tone_fields(&s); + assert_common_color_fields(&s); + assert_common_detail_fields(&s); + assert_common_geometry_fields(&s); +} + +fn assert_common_tone_fields(s: &DevelopSettings) { assert_eq!(s.wb.mode, WbMode::Custom); assert_eq!((s.wb.temp, s.wb.tint), (5150.0, 12.0)); assert_eq!(s.light.exposure, 0.65); @@ -54,15 +61,24 @@ fn maps_common_fields() { assert_eq!(s.curve.refine_saturation, 40.0); assert!((s.curve.master[1].x - 64.0 / 255.0).abs() < 1e-9 && (s.curve.master[1].y - 52.0 / 255.0).abs() < 1e-9); assert!(s.curve.red.is_empty(), "identity channel curve maps to no curve"); +} + +fn assert_common_color_fields(s: &DevelopSettings) { assert_eq!((s.grading.shadows.hue, s.grading.shadows.sat), (210.0, 14.0)); assert_eq!((s.grading.highlights.hue, s.grading.highlights.sat), (40.0, 9.0)); assert_eq!((s.grading.midtones.hue, s.grading.midtones.sat), (120.0, 6.0)); assert_eq!((s.grading.balance, s.grading.blending), (20.0, 70.0)); +} + +fn assert_common_detail_fields(s: &DevelopSettings) { assert_eq!((s.detail.sharpen_amount, s.detail.sharpen_radius, s.detail.sharpen_detail, s.detail.sharpen_masking), (55.0, 1.2, 30.0, 12.0)); assert_eq!((s.detail.nr_luminance, s.detail.nr_color), (18.0, 25.0)); assert_eq!(s.vignette.amount, -21.0); assert_eq!(s.vignette.style, VignetteStyle::ColorPriority); assert_eq!((s.grain.amount, s.grain.size, s.grain.roughness), (15.0, 30.0, 60.0)); +} + +fn assert_common_geometry_fields(s: &DevelopSettings) { assert_eq!(s.geometry.upright, Upright::Level); assert_eq!(s.geometry.vertical, -10.0); assert!(s.optics.remove_ca); diff --git a/crates/engine/src/export/tests.rs b/crates/engine/src/export/tests.rs index 80eb13430..139939a1d 100644 --- a/crates/engine/src/export/tests.rs +++ b/crates/engine/src/export/tests.rs @@ -1067,6 +1067,11 @@ fn avif_ten_bit() { /// unknown keys (naming the nearest known one) and values of the wrong kind or range. #[test] fn export_params_are_validated() { + assert_invalid_export_params(); + assert_export_options_round_trip(); +} + +fn assert_invalid_export_params() { use serde_json::json; let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default(); let e = err(json!({"longEdgee": 400})); @@ -1092,6 +1097,11 @@ fn export_params_are_validated() { .to_string() .starts_with("invalid parameters for `export.savePreset`") ); +} + +fn assert_export_options_round_trip() { + use serde_json::json; + let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default(); // everything the presets, the dialog, Export with Previous and `render --opt` send passes for p in builtin_presets() { ExportOptions::from_params(&p.params).unwrap_or_else(|e| panic!("{}: {e}", p.name)); diff --git a/crates/engine/src/sidecar/tests_repair_companions.rs b/crates/engine/src/sidecar/tests_repair_companions.rs index 94c7996a7..05402f975 100644 --- a/crates/engine/src/sidecar/tests_repair_companions.rs +++ b/crates/engine/src/sidecar/tests_repair_companions.rs @@ -73,85 +73,85 @@ fn partial_move_companions_allow_save_and_autowrite_without_weakening_collisions for upper_full in [false, true] { for naming in [SidecarNaming::Stem, SidecarNaming::Full] { for auto in [false, true] { - let dir = crate::tests_xmp::temp_dir(&format!("r3-companions-{upper_stem}-{upper_full}-{naming:?}-{auto}")); - let a = dir.join("a.jpg"); - let b = dir.join("Trip.jpg"); - std::fs::write(&a, b"physical fixture; Session rename does not decode").unwrap(); - let path = |photo: &Path, style, upper| { - let path = sidecar_path(&photo.to_string_lossy(), style); - if upper { path.with_extension("XMP") } else { path } - }; - let stem_a = path(&a, SidecarNaming::Stem, upper_stem); - let full_a = path(&a, SidecarNaming::Full, upper_full); - let stem_b = path(&b, SidecarNaming::Stem, upper_stem); - let full_b = path(&b, SidecarNaming::Full, upper_full); - let stem_bytes = packet("stem-keep"); - let full_bytes = packet("full-keep"); - std::fs::write(&stem_a, &stem_bytes).unwrap(); - std::fs::write(&full_a, &full_bytes).unwrap(); - let mut s = session(&a); - s.xmp.naming = naming; - s.xmp.auto_write = auto; - let fs = Faults(vec![full_a.clone(), b.clone()]); - let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1); - assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); - assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() }); - let pending = queues(&s); - assert_eq!(pending.len(), 1); - assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); - - // Missing packet and an unrelated packet at either case spelling remain protected. - let held = dir.join("held.xmp"); - std::fs::rename(&full_a, &held).unwrap(); - s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap(); - assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1); - assert_eq!(queues(&s), pending); - assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); - std::fs::rename(&held, &full_a).unwrap(); - for collision in [full_b.clone(), full_b.with_extension(if upper_full { "xmp" } else { "XMP" })] { - std::fs::write(&collision, packet("unrelated-packet")).unwrap(); - let occupied = std::fs::read(&collision).unwrap(); - assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1); - assert_eq!(queues(&s), pending); - assert_eq!(std::fs::read(&collision).unwrap(), occupied); - assert_eq!(std::fs::read_to_string(&full_a).unwrap(), full_bytes); - assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); - std::fs::remove_file(collision).unwrap(); - } - // AutoWrite is the first successful repair when enabled; explicit save otherwise. - s.execute("photo.rate", &json!({"rating": 4})).unwrap(); - if !auto { - assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 0); - } - assert!(queues(&s).is_empty()); - assert!(!full_a.exists()); - let (written, untouched, expected) = - if naming == SidecarNaming::Stem { (&stem_b, &full_b, &full_bytes) } else { (&full_b, &stem_b, &stem_bytes) }; - assert_fresh(written); - assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected); - assert_eq!(parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(4)); - for (command, rating) in [("edit.undo", 0), ("edit.redo", 4)] { - s.execute(command, &json!({})).unwrap(); - if !auto { - save(&mut s); - } - assert_fresh(written); - assert_eq!( - parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, - Some(rating) - ); - assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected); - } - assert_eq!(s.xmp.naming, naming); - assert_eq!(s.xmp.auto_write, auto); - drop(s); - std::fs::remove_dir_all(dir).unwrap(); + check_companion_scenario(upper_stem, upper_full, naming, auto); } } } } } +fn check_companion_scenario(upper_stem: bool, upper_full: bool, naming: SidecarNaming, auto: bool) { + let dir = crate::tests_xmp::temp_dir(&format!("r3-companions-{upper_stem}-{upper_full}-{naming:?}-{auto}")); + let a = dir.join("a.jpg"); + let b = dir.join("Trip.jpg"); + std::fs::write(&a, b"physical fixture; Session rename does not decode").unwrap(); + let path = |photo: &Path, style, upper| { + let path = sidecar_path(&photo.to_string_lossy(), style); + if upper { path.with_extension("XMP") } else { path } + }; + let stem_a = path(&a, SidecarNaming::Stem, upper_stem); + let full_a = path(&a, SidecarNaming::Full, upper_full); + let stem_b = path(&b, SidecarNaming::Stem, upper_stem); + let full_b = path(&b, SidecarNaming::Full, upper_full); + let stem_bytes = packet("stem-keep"); + let full_bytes = packet("full-keep"); + std::fs::write(&stem_a, &stem_bytes).unwrap(); + std::fs::write(&full_a, &full_bytes).unwrap(); + let mut s = session(&a); + s.xmp.naming = naming; + s.xmp.auto_write = auto; + let fs = Faults(vec![full_a.clone(), b.clone()]); + let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1); + assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); + assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() }); + let pending = queues(&s); + assert_eq!(pending.len(), 1); + assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); + + // Missing packet and an unrelated packet at either case spelling remain protected. + let held = dir.join("held.xmp"); + std::fs::rename(&full_a, &held).unwrap(); + s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap(); + assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1); + assert_eq!(queues(&s), pending); + assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); + std::fs::rename(&held, &full_a).unwrap(); + for collision in [full_b.clone(), full_b.with_extension(if upper_full { "xmp" } else { "XMP" })] { + std::fs::write(&collision, packet("unrelated-packet")).unwrap(); + let occupied = std::fs::read(&collision).unwrap(); + assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1); + assert_eq!(queues(&s), pending); + assert_eq!(std::fs::read(&collision).unwrap(), occupied); + assert_eq!(std::fs::read_to_string(&full_a).unwrap(), full_bytes); + assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); + std::fs::remove_file(collision).unwrap(); + } + // AutoWrite is the first successful repair when enabled; explicit save otherwise. + s.execute("photo.rate", &json!({"rating": 4})).unwrap(); + if !auto { + assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 0); + } + assert!(queues(&s).is_empty()); + assert!(!full_a.exists()); + let (written, untouched, expected) = + if naming == SidecarNaming::Stem { (&stem_b, &full_b, &full_bytes) } else { (&full_b, &stem_b, &stem_bytes) }; + assert_fresh(written); + assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected); + assert_eq!(parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(4)); + for (command, rating) in [("edit.undo", 0), ("edit.redo", 4)] { + s.execute(command, &json!({})).unwrap(); + if !auto { + save(&mut s); + } + assert_fresh(written); + assert_eq!(parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(rating)); + assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected); + } + assert_eq!(s.xmp.naming, naming); + assert_eq!(s.xmp.auto_write, auto); + drop(s); + std::fs::remove_dir_all(dir).unwrap(); +} #[test] fn rollback_repair_and_shared_copy_keep_known_companions() { for rollback in [false, true] { diff --git a/crates/engine/src/sidecar/tests_repair_provenance.rs b/crates/engine/src/sidecar/tests_repair_provenance.rs index 86f7d1dd6..9c9b7c084 100644 --- a/crates/engine/src/sidecar/tests_repair_provenance.rs +++ b/crates/engine/src/sidecar/tests_repair_provenance.rs @@ -348,64 +348,12 @@ fn detached_history_entry_is_checked_before_unrelated_global_retirement() { // A private-method contract, not a UI reproduction: application reachability of two // simultaneous repair owners is unproven. Native move failures and follow_stuck seed // both records; the second owner's normal apply_rename preflight is deliberately bypassed. - struct NativePartial { - id: PhotoId, - original: PathBuf, - moved: PathBuf, - stem: PathBuf, - full: PathBuf, - companion: PathBuf, - target: PathBuf, - } let root = crate::tests_xmp::temp_dir("detached-history-preflight-contract"); let image = lightcraft_raster::Rgba8::from_fn(4, 3, |_, _| [40, 80, 120, 255]); let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&image), &Default::default()).unwrap(); let mut s = Session::new(); s.xmp.naming = SidecarNaming::Stem; - let mut native = Vec::new(); - for (i, name) in ["first", "second"].into_iter().enumerate() { - let dir = root.join(name); - std::fs::create_dir_all(&dir).unwrap(); - let original = dir.join("a.png"); - let moved = dir.join("Trip.png"); - std::fs::write(&original, &png).unwrap(); - let stem = path(&original, SidecarNaming::Stem, false); - let full = path(&original, SidecarNaming::Full, true); - for (p, marker) in [(&stem, format!("{name}-stem")), (&full, format!("{name}-full"))] { - std::fs::write(p, packet(&marker)).unwrap(); - assert_eq!(parse_sidecar(&std::fs::read_to_string(p).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(5)); - } - let id = PhotoId(i as u64 + 1); - let photo = Photo::new(id, Source::File { path: original.to_string_lossy().into_owned() }, "a.png", "PNG", 4, 3, ""); - s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); - let fs = Faults(vec![full.clone(), moved.clone()]); - if i == 0 { - let plans = s.plan_rename_with(&fs, &[id], "Trip", 1); - assert!(s.apply_rename_with(&fs, &plans).is_err()); - } else { - // Register the genuine second partial move without draining the first repair. - let moves = vec![(original.to_string_lossy().into_owned(), moved.to_string_lossy().into_owned())]; - let err = crate::rename::move_all(&fs, &moves).unwrap_err(); - assert_eq!(err.stuck, moves); - assert_eq!(err.pending_sidecars.len(), 1); - let op = Op::SetFile { id, source: Source::File { path: moved.to_string_lossy().into_owned() }, file_name: "Trip.png".into() }; - s.follow_stuck(&op, err, true); - } - assert_eq!(s.undo.len(), i + 1); - assert_eq!(queued(&s).len(), i + 1); - assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: moved.to_string_lossy().into_owned() }); - let decoded = lightcraft_codecs::decode(&std::fs::read(&moved).unwrap(), Default::default()).unwrap(); - assert_eq!((decoded.width, decoded.height), (4, 3)); - native.push(NativePartial { - id, - original, - companion: path(&moved, SidecarNaming::Stem, false), - target: path(&moved, SidecarNaming::Full, true), - moved, - stem, - full, - }); - } + let native = create_native_partials(&root, &png, &mut s); let first = &native[0]; let second = &native[1]; let first_stem = std::fs::read(&first.companion).unwrap(); @@ -463,3 +411,61 @@ fn detached_history_entry_is_checked_before_unrelated_global_retirement() { drop(s); std::fs::remove_dir_all(root).unwrap(); } + +struct NativePartial { + id: PhotoId, + original: PathBuf, + moved: PathBuf, + stem: PathBuf, + full: PathBuf, + companion: PathBuf, + target: PathBuf, +} + +fn create_native_partials(root: &Path, png: &[u8], s: &mut Session) -> Vec { + let mut native = Vec::new(); + for (i, name) in ["first", "second"].into_iter().enumerate() { + let dir = root.join(name); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("a.png"); + let moved = dir.join("Trip.png"); + std::fs::write(&original, png).unwrap(); + let stem = path(&original, SidecarNaming::Stem, false); + let full = path(&original, SidecarNaming::Full, true); + for (p, marker) in [(&stem, format!("{name}-stem")), (&full, format!("{name}-full"))] { + std::fs::write(p, packet(&marker)).unwrap(); + assert_eq!(parse_sidecar(&std::fs::read_to_string(p).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(5)); + } + let id = PhotoId(i as u64 + 1); + let photo = Photo::new(id, Source::File { path: original.to_string_lossy().into_owned() }, "a.png", "PNG", 4, 3, ""); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + let fs = Faults(vec![full.clone(), moved.clone()]); + if i == 0 { + let plans = s.plan_rename_with(&fs, &[id], "Trip", 1); + assert!(s.apply_rename_with(&fs, &plans).is_err()); + } else { + // Register the genuine second partial move without draining the first repair. + let moves = vec![(original.to_string_lossy().into_owned(), moved.to_string_lossy().into_owned())]; + let err = crate::rename::move_all(&fs, &moves).unwrap_err(); + assert_eq!(err.stuck, moves); + assert_eq!(err.pending_sidecars.len(), 1); + let op = Op::SetFile { id, source: Source::File { path: moved.to_string_lossy().into_owned() }, file_name: "Trip.png".into() }; + s.follow_stuck(&op, err, true); + } + assert_eq!(s.undo.len(), i + 1); + assert_eq!(queued(s).len(), i + 1); + assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: moved.to_string_lossy().into_owned() }); + let decoded = lightcraft_codecs::decode(&std::fs::read(&moved).unwrap(), Default::default()).unwrap(); + assert_eq!((decoded.width, decoded.height), (4, 3)); + native.push(NativePartial { + id, + original, + companion: path(&moved, SidecarNaming::Stem, false), + target: path(&moved, SidecarNaming::Full, true), + moved, + stem, + full, + }); + } + native +} diff --git a/crates/engine/src/sidecar/tests_repair_saves.rs b/crates/engine/src/sidecar/tests_repair_saves.rs index 4b622e657..6fd50aea6 100644 --- a/crates/engine/src/sidecar/tests_repair_saves.rs +++ b/crates/engine/src/sidecar/tests_repair_saves.rs @@ -58,118 +58,122 @@ fn autowrite_repairs_partial_rename_before_merging_foreign_xmp_across_library_sw for save_naming in [SidecarNaming::Stem, SidecarNaming::Full] { for upper in [false, true] { for switch in [false, true] { - let root = crate::tests_xmp::temp_dir(&format!("repair-save-{naming:?}-{save_naming:?}-{upper}-{switch}")); - let a = root.join("a.png"); - let b = root.join("Trip.png"); - png(&a); - let mut old = sidecar_path(&a.to_string_lossy(), naming); - if upper { - old = old.with_extension("XMP"); - } - std::fs::write(&old, FOREIGN).unwrap(); - let mut s = Session::new().with_fs(); - s.open_library(root.join("library-a"), false).unwrap(); - s.execute("library.import", &json!({"paths": [a]})).unwrap(); - let mut id = s.catalog.photos().next().unwrap().id; - s.selection = crate::Selection::single(id); - s.undo.clear(); - let fs = PartialRename { sidecar: old.clone(), photo: b.clone() }; - let plans = s.plan_rename_with(&fs, &[id], "Trip", 1); - assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); - assert_eq!(queued(&s).len(), 1); - assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() }); - let pending = queued(&s); - let held = root.join("temporarily-unavailable.xmp"); - std::fs::rename(&old, &held).unwrap(); - if switch { - s.close_library().unwrap(); - s.open_library(root.join("library-b"), false).unwrap(); - assert_eq!(s.pending_sidecar_repairs, pending); - s.execute("library.import", &json!({"paths": [b]})).unwrap(); - id = s.catalog.photos().next().unwrap().id; - s.selection = crate::Selection::single(id); - } - s.xmp.naming = save_naming; - s.xmp.auto_write = true; - s.execute("photo.rate", &json!({"rating": 4})).unwrap(); - let preferred = sidecar_path(&b.to_string_lossy(), save_naming); - assert!(!preferred.exists() && !preferred.with_extension("XMP").exists()); - assert!(!pending[0].to.exists()); - assert_eq!(queued(&s), pending, "unavailable repair stays queued after AutoWrite"); - let saved = s.execute("photo.saveMetadataToFile", &json!({})).unwrap(); - assert!(saved["written"].as_array().unwrap().is_empty()); - assert!(saved["failed"][0]["error"].as_str().unwrap().contains("unfinished XMP repair")); - assert_eq!(std::fs::read(&held).unwrap(), FOREIGN); - assert_eq!(queued(&s), pending); - - // A missing sidecar for another photo does not prevent this healthy save. - let healthy = root.join("healthy.png"); - png(&healthy); - let healthy_id = PhotoId(999); - s.commit( - "Healthy Photo", - Op::AddPhoto { - photo: Box::new(Photo::new( - healthy_id, - Source::File { path: healthy.to_string_lossy().into_owned() }, - "healthy.png", - "PNG", - 4, - 3, - "", - )), - }, - ) - .unwrap(); - s.execute("photo.setMeta", &json!({"ids": [healthy_id.0], "title": "Healthy save"})).unwrap(); - let packet = std::fs::read_to_string(sidecar_path(&healthy.to_string_lossy(), save_naming)).unwrap(); - assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some("Healthy save")); - assert_eq!(queued(&s), pending); - - // An existing destination is never overwritten and the original packet remains. - std::fs::rename(&held, &old).unwrap(); - let destination = pending[0].to.clone(); - let collision = String::from_utf8(FOREIGN.to_vec()).unwrap().replace("Rating=\"2\"", "Rating=\"5\"").into_bytes(); - let collision_path = if upper { preferred.clone() } else { destination.clone() }; - std::fs::write(&collision_path, &collision).unwrap(); - s.execute("photo.setMeta", &json!({"ids": [id.0], "title": "Fresh title", "addKeywords": ["café"]})).unwrap(); - assert_eq!(std::fs::read(&old).unwrap(), FOREIGN); - assert_eq!(std::fs::read(&collision_path).unwrap(), collision); - assert_eq!(queued(&s), pending); - s.selection = crate::Selection::single(id); - assert_eq!(s.execute("photo.saveMetadataToFile", &json!({})).unwrap()["failed"].as_array().unwrap().len(), 1); - assert_eq!(std::fs::read(&collision_path).unwrap(), collision); - std::fs::remove_file(&collision_path).unwrap(); // remove only this test's collision - - // The next successful AutoWrite runs the real repair and production XMP merge. - s.execute("photo.setMeta", &json!({"title": "Final title"})).unwrap(); - assert!(queued(&s).is_empty()); - assert!(!old.exists()); - let packet = std::fs::read_to_string(&destination).unwrap(); - assert!(packet.contains("fixture:Marker=\"keep-me\"")); - let parsed = parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap(); - assert_eq!(parsed.rating, Some(4)); - assert_eq!(parsed.title.as_deref(), Some("Final title")); - assert!(parsed.keywords.as_ref().unwrap().contains(&"café".to_string())); - if upper { - // by listing: on a case-insensitive volume `preferred` answers for the upper-case packet - let listed = std::fs::read_dir(&root).unwrap().any(|e| Some(e.unwrap().file_name().as_os_str()) == preferred.file_name()); - assert!(!listed, "merge uses the repaired uppercase packet"); - } - for (step, title) in [("edit.undo", "Fresh title"), ("edit.redo", "Final title")] { - s.execute(step, &json!({})).unwrap(); - let packet = std::fs::read_to_string(&destination).unwrap(); - assert!(packet.contains("fixture:Marker=\"keep-me\"")); - assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some(title)); - } - drop(s); - std::fs::remove_dir_all(root).unwrap(); + check_autowrite_repair_scenario(naming, save_naming, upper, switch); } } } } } +fn check_autowrite_repair_scenario(naming: SidecarNaming, save_naming: SidecarNaming, upper: bool, switch: bool) { + let root = crate::tests_xmp::temp_dir(&format!("repair-save-{naming:?}-{save_naming:?}-{upper}-{switch}")); + let a = root.join("a.png"); + let b = root.join("Trip.png"); + png(&a); + let mut old = sidecar_path(&a.to_string_lossy(), naming); + if upper { + old = old.with_extension("XMP"); + } + std::fs::write(&old, FOREIGN).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(root.join("library-a"), false).unwrap(); + s.execute("library.import", &json!({"paths": [a]})).unwrap(); + let mut id = s.catalog.photos().next().unwrap().id; + s.selection = crate::Selection::single(id); + s.undo.clear(); + let fs = PartialRename { sidecar: old.clone(), photo: b.clone() }; + let plans = s.plan_rename_with(&fs, &[id], "Trip", 1); + assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); + assert_eq!(queued(&s).len(), 1); + assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() }); + let pending = queued(&s); + let held = root.join("temporarily-unavailable.xmp"); + std::fs::rename(&old, &held).unwrap(); + if switch { + s.close_library().unwrap(); + s.open_library(root.join("library-b"), false).unwrap(); + assert_eq!(s.pending_sidecar_repairs, pending); + s.execute("library.import", &json!({"paths": [b]})).unwrap(); + id = s.catalog.photos().next().unwrap().id; + s.selection = crate::Selection::single(id); + } + s.xmp.naming = save_naming; + s.xmp.auto_write = true; + s.execute("photo.rate", &json!({"rating": 4})).unwrap(); + let preferred = sidecar_path(&b.to_string_lossy(), save_naming); + assert!(!preferred.exists() && !preferred.with_extension("XMP").exists()); + assert!(!pending[0].to.exists()); + assert_eq!(queued(&s), pending, "unavailable repair stays queued after AutoWrite"); + let saved = s.execute("photo.saveMetadataToFile", &json!({})).unwrap(); + assert!(saved["written"].as_array().unwrap().is_empty()); + assert!(saved["failed"][0]["error"].as_str().unwrap().contains("unfinished XMP repair")); + assert_eq!(std::fs::read(&held).unwrap(), FOREIGN); + assert_eq!(queued(&s), pending); + + // A missing sidecar for another photo does not prevent this healthy save. + let healthy = root.join("healthy.png"); + png(&healthy); + let healthy_id = PhotoId(999); + s.commit( + "Healthy Photo", + Op::AddPhoto { + photo: Box::new(Photo::new(healthy_id, Source::File { path: healthy.to_string_lossy().into_owned() }, "healthy.png", "PNG", 4, 3, "")), + }, + ) + .unwrap(); + s.execute("photo.setMeta", &json!({"ids": [healthy_id.0], "title": "Healthy save"})).unwrap(); + let packet = std::fs::read_to_string(sidecar_path(&healthy.to_string_lossy(), save_naming)).unwrap(); + assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some("Healthy save")); + assert_eq!(queued(&s), pending); + + // An existing destination is never overwritten and the original packet remains. + std::fs::rename(&held, &old).unwrap(); + let destination = pending[0].to.clone(); + let collision = String::from_utf8(FOREIGN.to_vec()).unwrap().replace("Rating=\"2\"", "Rating=\"5\"").into_bytes(); + let collision_path = if upper { preferred.clone() } else { destination.clone() }; + std::fs::write(&collision_path, &collision).unwrap(); + s.execute("photo.setMeta", &json!({"ids": [id.0], "title": "Fresh title", "addKeywords": ["café"]})).unwrap(); + assert_eq!(std::fs::read(&old).unwrap(), FOREIGN); + assert_eq!(std::fs::read(&collision_path).unwrap(), collision); + assert_eq!(queued(&s), pending); + s.selection = crate::Selection::single(id); + assert_eq!(s.execute("photo.saveMetadataToFile", &json!({})).unwrap()["failed"].as_array().unwrap().len(), 1); + assert_eq!(std::fs::read(&collision_path).unwrap(), collision); + std::fs::remove_file(&collision_path).unwrap(); // remove only this test's collision + + // The next successful AutoWrite runs the real repair and production XMP merge. + s.execute("photo.setMeta", &json!({"title": "Final title"})).unwrap(); + assert!(queued(&s).is_empty()); + assert!(!old.exists()); + let packet = std::fs::read_to_string(&destination).unwrap(); + assert!(packet.contains("fixture:Marker=\"keep-me\"")); + let parsed = parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap(); + assert_eq!(parsed.rating, Some(4)); + assert_eq!(parsed.title.as_deref(), Some("Final title")); + assert!(parsed.keywords.as_ref().unwrap().contains(&"café".to_string())); + assert_repaired_case_spelling(&root, &preferred, upper); + assert_undo_redo_preserve_foreign_packet(&mut s, &destination); + drop(s); + std::fs::remove_dir_all(root).unwrap(); +} + +fn assert_repaired_case_spelling(root: &Path, preferred: &Path, upper: bool) { + if !upper { + return; + } + // By listing, a case-insensitive volume answers for the uppercase packet. + let listed = std::fs::read_dir(root).unwrap().any(|e| Some(e.unwrap().file_name().as_os_str()) == preferred.file_name()); + assert!(!listed, "merge uses the repaired uppercase packet"); +} + +fn assert_undo_redo_preserve_foreign_packet(s: &mut Session, destination: &Path) { + for (step, title) in [("edit.undo", "Fresh title"), ("edit.redo", "Final title")] { + s.execute(step, &json!({})).unwrap(); + let packet = std::fs::read_to_string(destination).unwrap(); + assert!(packet.contains("fixture:Marker=\"keep-me\"")); + assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some(title)); + } +} #[test] fn unowned_shared_stem_repair_does_not_block_healthy_jpeg_full_sidecar_save() { let root = crate::tests_xmp::temp_dir("repair-save-shared-stem"); diff --git a/crates/engine/src/tests/import.rs b/crates/engine/src/tests/import.rs index 6e5585a82..dc1c6c8f2 100644 --- a/crates/engine/src/tests/import.rs +++ b/crates/engine/src/tests/import.rs @@ -1,7 +1,11 @@ //! Import: recursive folders, duplicate detection by content, add vs copy into the library. -use std::path::Path; +use std::{ + path::{Path, PathBuf}, + sync::atomic::AtomicUsize, +}; +use lightcraft_catalog::PhotoId; use serde_json::{Value, json}; use crate::Session; @@ -1711,109 +1715,139 @@ fn smart_previews_from_an_older_look_fit_are_refreshed() { } fn smart_look_refresh_for(format: &str) { - use std::sync::atomic::{AtomicUsize, Ordering}; - let src = temp_dir(&format!("smartlook-src-{format}")); - let lib = temp_dir(&format!("smartlook-lib-{format}")); - write_png(&src.join("a.png"), 3); - write_png(&src.join("b.png"), 9); - let mut s = Session::new().with_fs(); - s.open_library(&lib, false).unwrap(); - s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy(), src.join("b.png").to_string_lossy()]})).unwrap(); - let find = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id; - let (id, png) = (find(&s, "a.png"), find(&s, "b.png")); - // `a` stands for a raw that takes the per-file camera look (the test loader decodes its pixels) - let mut raw = (**s.catalog.photo(id).unwrap()).clone(); - raw.format = format.into(); - raw.kind = lightcraft_catalog::MediaKind::Raw; - s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap(); - s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap(); - assert_eq!(s.catalog.photo(id).unwrap().relative_wb(), format == "ARW"); - assert!(!s.catalog.photo(png).unwrap().relative_wb()); - s.execute("library.select", &json!({"ids": [id.0, png.0], "active": id.0})).unwrap(); - let dir = s.media.smart_dir.clone().unwrap(); - let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap()); - let png_file = crate::smart::sharded_file_path(&dir, s.catalog.photo(png).unwrap()); - let (old, new) = (test_tone(1.5), test_tone(3.5)); - let calls = std::sync::Arc::new(AtomicUsize::new(0)); - let plain = s.media.file_loader.clone().unwrap(); - s.media.file_loader = Some(look_loader(plain.clone(), old, calls.clone())); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + let mut case = SmartLookCase::new(format); + assert_current_smart_look_is_not_rebuilt(&mut case); + let (old_bytes, png_bytes) = keep_offline_previews(&mut case); + refresh_online_previews(&mut case, &old_bytes, &png_bytes); + let _ = std::fs::remove_dir_all(&case.src); + let _ = std::fs::remove_dir_all(&case.lib); +} + +struct SmartLookCase { + src: PathBuf, + lib: PathBuf, + session: Session, + id: PhotoId, + dir: PathBuf, + file: PathBuf, + png_file: PathBuf, + old: lightcraft_pipeline::tone::CameraTone, + new: lightcraft_pipeline::tone::CameraTone, + calls: std::sync::Arc, + plain_loader: crate::media::FileLoader, +} + +impl SmartLookCase { + fn new(format: &str) -> Self { + let src = temp_dir(&format!("smartlook-src-{format}")); + let lib = temp_dir(&format!("smartlook-lib-{format}")); + write_png(&src.join("a.png"), 3); + write_png(&src.join("b.png"), 9); + let mut session = Session::new().with_fs(); + session.open_library(&lib, false).unwrap(); + session.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy(), src.join("b.png").to_string_lossy()]})).unwrap(); + let find = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id; + let (id, png_id) = (find(&session, "a.png"), find(&session, "b.png")); + // `a` stands for a raw that takes the per-file camera look (the test loader decodes its pixels) + let mut raw = (**session.catalog.photo(id).unwrap()).clone(); + raw.format = format.into(); + raw.kind = lightcraft_catalog::MediaKind::Raw; + session.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap(); + session.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap(); + assert_eq!(session.catalog.photo(id).unwrap().relative_wb(), format == "ARW"); + assert!(!session.catalog.photo(png_id).unwrap().relative_wb()); + session.execute("library.select", &json!({"ids": [id.0, png_id.0], "active": id.0})).unwrap(); + let dir = session.media.smart_dir.clone().unwrap(); + let file = crate::smart::sharded_file_path(&dir, session.catalog.photo(id).unwrap()); + let png_file = crate::smart::sharded_file_path(&dir, session.catalog.photo(png_id).unwrap()); + let (old, new) = (test_tone(1.5), test_tone(3.5)); + let calls = std::sync::Arc::new(AtomicUsize::new(0)); + let plain_loader = session.media.file_loader.clone().unwrap(); + session.media.file_loader = Some(look_loader(plain_loader.clone(), old, calls.clone())); + Self { src, lib, session, id, dir, file, png_file, old, new, calls, plain_loader } + } +} + +fn assert_current_smart_look_is_not_rebuilt(case: &mut SmartLookCase) { + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(2), Some(0)), "{r}"); - assert_eq!(smart_header(&file)["look_version"], json!(crate::camera_preview::LOOK_VERSION), "the writer stamps the fit version"); - assert!(!crate::smart::is_stale(&file)); + assert_eq!(smart_header(&case.file)["look_version"], json!(crate::camera_preview::LOOK_VERSION), "the writer stamps the fit version"); + assert!(!crate::smart::is_stale(&case.file)); // current header: kept as it is, the original is not even read - let before = std::fs::read(&file).unwrap(); - calls.store(0, Ordering::SeqCst); - s.media.file_loader = Some(look_loader(plain.clone(), new, calls.clone())); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + let before = std::fs::read(&case.file).unwrap(); + case.calls.store(0, std::sync::atomic::Ordering::SeqCst); + case.session.media.file_loader = Some(look_loader(case.plain_loader.clone(), case.new, case.calls.clone())); + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(2), Some(0)), "{r}"); - assert!(std::fs::read(&file).unwrap() == before, "a current proxy is not rebuilt"); - assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!(std::fs::read(&case.file).unwrap(), before, "a current proxy is not rebuilt"); + assert_eq!(case.calls.load(std::sync::atomic::Ordering::SeqCst), 0); +} +fn keep_offline_previews(case: &mut SmartLookCase) -> (Vec, Vec) { // old headers (no field), originals offline: kept unchanged, and still marked stale - for f in [&file, &png_file] { + for f in [&case.file, &case.png_file] { edit_smart_header(f, |h| { h.as_object_mut().unwrap().remove("look_version"); }); } - assert!(crate::smart::is_stale(&file), "no field = version 0"); - let old_bytes = std::fs::read(&file).unwrap(); - let png_bytes = std::fs::read(&png_file).unwrap(); - std::fs::rename(&src, src.with_extension("offline")).unwrap(); - s.media.forget(id); - s.media.forget(png); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert!(crate::smart::is_stale(&case.file), "no field = version 0"); + let old_bytes = std::fs::read(&case.file).unwrap(); + let png_bytes = std::fs::read(&case.png_file).unwrap(); + std::fs::rename(&case.src, case.src.with_extension("offline")).unwrap(); + case.session.media.forget(case.id); + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64(), r["staleKept"].as_u64()), (Some(2), Some(0), Some(1)), "{r}"); assert!(r["failed"].as_array().unwrap().is_empty(), "{r}"); - assert!(std::fs::read(&file).unwrap() == old_bytes, "offline: the stored preview and curve are untouched"); - assert!(crate::smart::is_stale(&file), "and it stays marked for the next time"); - assert_eq!(crate::smart::decode(&old_bytes).unwrap().1, Some(old), "the old look is still there"); + assert_eq!(std::fs::read(&case.file).unwrap(), old_bytes, "offline: the stored preview and curve are untouched"); + assert!(crate::smart::is_stale(&case.file), "and it stays marked for the next time"); + assert_eq!(crate::smart::decode(&old_bytes).unwrap().1, Some(case.old), "the old look is still there"); // ... and the opening refresh keeps it too, then marks the folder checked - assert!(!crate::smart::look_checked(&dir)); - crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap(); - assert!(std::fs::read(&file).unwrap() == old_bytes); - assert!(crate::smart::look_checked(&dir)); + assert!(!crate::smart::look_checked(&case.dir)); + crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).unwrap().join().unwrap(); + assert_eq!(std::fs::read(&case.file).unwrap(), old_bytes); + assert!(crate::smart::look_checked(&case.dir)); + (old_bytes, png_bytes) +} +fn refresh_online_previews(case: &mut SmartLookCase, old_bytes: &[u8], png_bytes: &[u8]) { // the original comes back: the scan has run at this version, so the next opening leaves it; // Build Smart Previews rebuilds it with the current look - std::fs::rename(src.with_extension("offline"), &src).unwrap(); - calls.store(0, Ordering::SeqCst); - assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut s).is_none(), "checked once per version"); - assert!(std::fs::read(&file).unwrap() == old_bytes); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); - assert_eq!((r["refreshed"].as_u64(), calls.load(Ordering::SeqCst)), (Some(1), 1), "{r}"); - assert_eq!(smart_header(&file)["look_version"], json!(crate::camera_preview::LOOK_VERSION)); - assert_eq!(crate::smart::decode(&std::fs::read(&file).unwrap()).unwrap().1, Some(new), "rebuilt with the current look"); - assert!(!crate::smart::is_stale(&file)); - assert!(std::fs::read(&png_file).unwrap() == png_bytes, "a PNG's proxy is never rebuilt for the look"); + std::fs::rename(case.src.with_extension("offline"), &case.src).unwrap(); + case.calls.store(0, std::sync::atomic::Ordering::SeqCst); + assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).is_none(), "checked once per version"); + assert_eq!(std::fs::read(&case.file).unwrap(), old_bytes); + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["refreshed"].as_u64(), case.calls.load(std::sync::atomic::Ordering::SeqCst)), (Some(1), 1), "{r}"); + assert_eq!(smart_header(&case.file)["look_version"], json!(crate::camera_preview::LOOK_VERSION)); + assert_eq!(crate::smart::decode(&std::fs::read(&case.file).unwrap()).unwrap().1, Some(case.new), "rebuilt with the current look"); + assert!(!crate::smart::is_stale(&case.file)); + assert_eq!(std::fs::read(&case.png_file).unwrap(), png_bytes, "a PNG's proxy is never rebuilt for the look"); // a folder checked at an older version (or not at all) is scanned once at the next opening - edit_smart_header(&file, |h| h["look_version"] = json!(0)); - std::fs::write(dir.join("look-version"), b"0\n").unwrap(); - assert!(!crate::smart::look_checked(&dir)); - s.media.forget(id); - calls.store(0, Ordering::SeqCst); - crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap(); - assert_eq!(calls.load(Ordering::SeqCst), 1, "only the raw's original is read"); - assert!(!crate::smart::is_stale(&file)); - assert!(std::fs::read(&png_file).unwrap() == png_bytes); - assert!(crate::smart::look_checked(&dir)); + edit_smart_header(&case.file, |h| h["look_version"] = json!(0)); + std::fs::write(case.dir.join("look-version"), b"0\n").unwrap(); + assert!(!crate::smart::look_checked(&case.dir)); + case.session.media.forget(case.id); + case.calls.store(0, std::sync::atomic::Ordering::SeqCst); + crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).unwrap().join().unwrap(); + assert_eq!(case.calls.load(std::sync::atomic::Ordering::SeqCst), 1, "only the raw's original is read"); + assert!(!crate::smart::is_stale(&case.file)); + assert_eq!(std::fs::read(&case.png_file).unwrap(), png_bytes); + assert!(crate::smart::look_checked(&case.dir)); // once only - let rebuilt = std::fs::read(&file).unwrap(); - assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut s).is_none()); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); - assert_eq!((r["refreshed"].as_u64(), calls.load(Ordering::SeqCst)), (Some(0), 1), "{r}"); - assert!(std::fs::read(&file).unwrap() == rebuilt); + let rebuilt = std::fs::read(&case.file).unwrap(); + assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).is_none()); + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["refreshed"].as_u64(), case.calls.load(std::sync::atomic::Ordering::SeqCst)), (Some(0), 1), "{r}"); + assert_eq!(std::fs::read(&case.file).unwrap(), rebuilt); // Build Smart Previews refreshes a stale one the same way - edit_smart_header(&file, |h| h["look_version"] = json!(0)); - s.media.file_loader = Some(plain); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + edit_smart_header(&case.file, |h| h["look_version"] = json!(0)); + case.session.media.file_loader = Some(case.plain_loader.clone()); + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!(r["refreshed"], 1, "{r}"); - assert_eq!(crate::smart::decode(&std::fs::read(&file).unwrap()).unwrap().1, None, "this fit gives no curve: none is kept"); - let _ = std::fs::remove_dir_all(&src); - let _ = std::fs::remove_dir_all(&lib); + assert_eq!(crate::smart::decode(&std::fs::read(&case.file).unwrap()).unwrap().1, None, "this fit gives no curve: none is kept"); } /// Issue #1534 (review): a proxy's encoding version is migrated independently of the per-file From c9d44f7804075fa4ecfb48a524bb57d8c1314564 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:19:34 +0000 Subject: [PATCH 19/39] style(engine): expand module imports explicitly --- crates/catalog/examples/persist_bench.rs | 2 +- crates/engine/src/camera_preview/colour.rs | 2 +- crates/engine/src/camera_preview/hue_sat.rs | 2 +- crates/engine/src/camera_preview/look_fit.rs | 2 +- crates/engine/src/camera_preview/mono.rs | 2 +- crates/engine/src/camera_preview/pairs.rs | 2 +- crates/engine/src/camera_preview/proxy.rs | 2 +- crates/engine/src/camera_preview/tone.rs | 2 +- crates/engine/src/cmd/geocode.rs | 5 ++++- crates/engine/src/cmd/library/filters.rs | 2 +- crates/engine/src/cmd/library/import.rs | 2 +- crates/engine/src/cmd/library/selection.rs | 2 +- crates/engine/src/cmd/library/specs.rs | 5 ++++- crates/engine/src/denoise/model.rs | 3 +-- crates/engine/src/denoise/products.rs | 5 ++++- crates/engine/src/denoise/session.rs | 5 ++++- crates/engine/src/denoise/settings.rs | 2 +- crates/engine/src/denoise/state.rs | 2 +- crates/engine/src/export/batch.rs | 2 +- crates/engine/src/export/encoding.rs | 5 ++++- crates/engine/src/export/options.rs | 2 +- crates/engine/src/export/params.rs | 2 +- crates/engine/src/export/place.rs | 2 +- crates/engine/src/export/prepare.rs | 2 +- crates/engine/src/export/watermark.rs | 2 +- crates/engine/src/files/fs.rs | 2 +- crates/engine/src/files/load.rs | 2 +- crates/engine/src/files/preview.rs | 2 +- crates/engine/src/files/probe.rs | 2 +- crates/engine/src/import/commit.rs | 2 +- crates/engine/src/import/defaults.rs | 2 +- crates/engine/src/import/files.rs | 3 ++- crates/engine/src/import/job.rs | 2 +- crates/engine/src/import/scan.rs | 2 +- crates/engine/src/import/types.rs | 2 +- crates/engine/src/media/cache.rs | 2 +- crates/engine/src/media/quick.rs | 2 +- crates/engine/src/media/session.rs | 2 +- crates/engine/src/media/settings_hash.rs | 2 +- crates/engine/src/media/source.rs | 2 +- crates/engine/src/rename/fs.rs | 2 +- crates/engine/src/rename/moves.rs | 2 +- crates/engine/src/rename/plan.rs | 2 +- crates/engine/src/rename/sidecar.rs | 2 +- crates/engine/src/rename/tokens.rs | 2 +- xtask/src/ci.rs | 2 +- xtask/src/corpus.rs | 2 +- xtask/src/layers.rs | 2 +- xtask/src/wasm.rs | 2 +- 49 files changed, 65 insertions(+), 50 deletions(-) diff --git a/crates/catalog/examples/persist_bench.rs b/crates/catalog/examples/persist_bench.rs index 693e862d0..1fc05588c 100644 --- a/crates/catalog/examples/persist_bench.rs +++ b/crates/catalog/examples/persist_bench.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use std::time::Instant; -use lightcraft_catalog::*; +use lightcraft_catalog::{Catalog, DEFAULT_FORGET_DAYS, FsStore, HistoryStep, Journal, MediaKind, Op, Photo, PhotoId, Source, folder_of}; use lightcraft_develop::DevelopSettings; fn ms(t: Instant) -> f64 { diff --git a/crates/engine/src/camera_preview/colour.rs b/crates/engine/src/camera_preview/colour.rs index a33a5c7a5..2681466a9 100644 --- a/crates/engine/src/camera_preview/colour.rs +++ b/crates/engine/src/camera_preview/colour.rs @@ -1,7 +1,7 @@ use super::look_fit::{fit_dng_tone, fit_preview}; use super::mono::{fit_dark_tone, monochrome_look}; use super::proxy::{LensGeometry, proxies}; -use super::*; +use super::{CameraLook, CameraTone, CameraTransform, ColorData, D50, D65, Mat3, ProfileLook, REC2020, RawFormat, RawImage, ToneCurve, Xy, bradford}; /// Long edge of the sensor/JPEG proxy a single photo's look is fitted on (the acceptance gates /// below were set at this size). diff --git a/crates/engine/src/camera_preview/hue_sat.rs b/crates/engine/src/camera_preview/hue_sat.rs index 7e92ba519..b3692dc4f 100644 --- a/crates/engine/src/camera_preview/hue_sat.rs +++ b/crates/engine/src/camera_preview/hue_sat.rs @@ -1,6 +1,6 @@ use super::mono::to_prophoto; use super::pairs::{displayed, luma}; -use super::*; +use super::{CameraLook, CameraTone, HsvTable, Mat3, ToneMap, luminance_2020}; /// [`HueSat::apply`] restores the luminance in full only if the table kept at least this share of it. A colour /// the table darkens to [`NO_LUMINANCE_KEPT`] or less (only near-black noise, whose channels go negative) keeps diff --git a/crates/engine/src/camera_preview/look_fit.rs b/crates/engine/src/camera_preview/look_fit.rs index c1588093b..544db5f5f 100644 --- a/crates/engine/src/camera_preview/look_fit.rs +++ b/crates/engine/src/camera_preview/look_fit.rs @@ -3,7 +3,7 @@ use super::mono::{fit_tone_only_with, monochrome}; use super::pairs::{Attempt, TONE_FITS, fit_attempt, fit_partial, search_ordered}; use super::proxy::{CLIP_TOLERANCE, LensGeometry, MIN_PREVIEW_FRACTION, proxies, structure_agrees}; use super::tone::{HighlightCheck, highlights}; -use super::*; +use super::{CameraLook, CameraTone, CameraTransform, HsvTable, Mat3, RawImage, Rgb32f, ToneMap, luminance_2020}; /// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]). pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> (Option, bool) { diff --git a/crates/engine/src/camera_preview/mono.rs b/crates/engine/src/camera_preview/mono.rs index cfac90c34..3c0bd5736 100644 --- a/crates/engine/src/camera_preview/mono.rs +++ b/crates/engine/src/camera_preview/mono.rs @@ -7,7 +7,7 @@ use super::pairs::{ }; use super::proxy::{LensGeometry, proxies}; use super::tone::{HighlightCheck, ToneFit, fit_tone, highlights, highlights_hold}; -use super::*; +use super::{CameraLook, CameraTransform, D50, D65, HsvTable, Mat3, PROPHOTO, REC2020, RawImage, Rgb32f, ToneMap, bradford, luminance_2020}; /// A camera JPEG pixel's channels may differ by this much (linear) and still count as grey. const GREY_SPREAD: f32 = 0.002; diff --git a/crates/engine/src/camera_preview/pairs.rs b/crates/engine/src/camera_preview/pairs.rs index 2fce1a871..425f267da 100644 --- a/crates/engine/src/camera_preview/pairs.rs +++ b/crates/engine/src/camera_preview/pairs.rs @@ -3,7 +3,7 @@ use super::proxy::DARK_FLOOR; #[cfg(test)] use super::tone::highlights; use super::tone::{HighlightCheck, ToneFit, highlights_hold}; -use super::*; +use super::{CameraLook, CameraTone, HsvTable, Mat3, Rgb32f, ToneMap, luminance_2020}; /// A fit must cut the held-out squared error to below this share of the fallback's. pub(super) const MIN_IMPROVEMENT: f64 = 0.7; diff --git a/crates/engine/src/camera_preview/proxy.rs b/crates/engine/src/camera_preview/proxy.rs index 81f170a87..b3db0a160 100644 --- a/crates/engine/src/camera_preview/proxy.rs +++ b/crates/engine/src/camera_preview/proxy.rs @@ -2,7 +2,7 @@ use super::colour::{PROFILE_PROXY, PROXY, file_local_look}; use super::hue_sat::fit_hue_sat; use super::look_fit::{FIT_PREVIEW_MAX_PIXELS, SENSOR_CLIP, clip_mask, without_clipped}; use super::pairs::{collect_pairs, fit_matrix}; -use super::*; +use super::{CameraTransform, Filter, HsvTable, Mat3, RawFormat, RawImage, Rgb32f, fit, luminance_2020, resize}; /// The embedded preview's long edge must be at least this share of the raw's (and 384 px): a DNG whose preview is a /// small thumbnail is not fitted. The fit itself runs on a proxy of at most 384 px, so the 1024 px preview diff --git a/crates/engine/src/camera_preview/tone.rs b/crates/engine/src/camera_preview/tone.rs index 19b66948c..f16b8e899 100644 --- a/crates/engine/src/camera_preview/tone.rs +++ b/crates/engine/src/camera_preview/tone.rs @@ -1,6 +1,6 @@ use super::look_fit::dro_off; use super::pairs::{Pairs, displayed, luma}; -use super::*; +use super::{CameraTone, Rgb32f, ToneMap, luminance_2020}; /// How the per-photo tone curve is fitted ([`fit_pairs_ordered`] tries them in order). #[derive(Clone, Copy, PartialEq, Eq, Debug)] diff --git a/crates/engine/src/cmd/geocode.rs b/crates/engine/src/cmd/geocode.rs index feb9b033e..63f8d8f17 100644 --- a/crates/engine/src/cmd/geocode.rs +++ b/crates/engine/src/cmd/geocode.rs @@ -188,7 +188,10 @@ mod download { use lightcraft_geocode::{GEONAMES_BASE, GEONAMES_ZIP}; - use super::*; + use super::{ + Cancel, DownloadOutcome, GEONAMES_ATTRIBUTION, GEONAMES_FILES, GeoNames, PoisonError, Result, Session, Value, bad, dataset_dir, json, + str_param, + }; use crate::activity::Unit; const MAX_ZIP: u64 = 64 * 1024 * 1024; diff --git a/crates/engine/src/cmd/library/filters.rs b/crates/engine/src/cmd/library/filters.rs index 1eaa5223f..f0a3a504f 100644 --- a/crates/engine/src/cmd/library/filters.rs +++ b/crates/engine/src/cmd/library/filters.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{AlbumId, LibrarySource, PhotoId, Result, Session, Value, bad}; /// `base` with a partial Filter (JSON) merged on top. /// `patch` merged onto `base`, its rules brought up to date (`RuleSet::upgrade`), unchecked (see diff --git a/crates/engine/src/cmd/library/import.rs b/crates/engine/src/cmd/library/import.rs index 81b82671f..8437bf165 100644 --- a/crates/engine/src/cmd/library/import.rs +++ b/crates/engine/src/cmd/library/import.rs @@ -1,5 +1,5 @@ use super::selection::strs; -use super::*; +use super::{AlbumId, PhotoId, Result, Selection, Session, Value, bad, bool_or, json, str_param}; /// The auto-import folder's visible files with their sizes (the file-system half of /// `library.autoImportScan`; the app lists on a worker thread and passes `listing`). diff --git a/crates/engine/src/cmd/library/selection.rs b/crates/engine/src/cmd/library/selection.rs index 1ed1868d7..723b7c331 100644 --- a/crates/engine/src/cmd/library/selection.rs +++ b/crates/engine/src/cmd/library/selection.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{AlbumId, Flag, Op, PhotoId, Result, Selection, Session, Value, bad, bool_or, json, ok}; /// The selection after photos left the view (deleted, or rated, flagged or labelled out of it). /// Its callers end an open edit (a slider drag) before their own change, as moving to the next diff --git a/crates/engine/src/cmd/library/specs.rs b/crates/engine/src/cmd/library/specs.rs index 07ab6f221..2e7c1311d 100644 --- a/crates/engine/src/cmd/library/specs.rs +++ b/crates/engine/src/cmd/library/specs.rs @@ -5,7 +5,10 @@ use super::selection::{ album_param, album_targets, album_targets_or_err, change_flag, change_rating, cull, flip, for_targets, has_library, mark_deleted, opt_album_param, rotate, row_step, select_after_deletion, select_by, step, strs, }; -use super::*; +use super::{ + Album, AlbumId, ColorLabel, CommandSpec, CopyrightStatus, Filter, Flag, GroupBy, LibrarySource, Op, PhotoId, Result, Selection, Sort, SortKey, + Value, always, bad, bool_or, cmd, has_active, has_library_selection, has_selection, json, ok, str_param, +}; /// Seeds stay exactly representable as a JSON double, so web and agent clients read back what they wrote. const MAX_SEED: u64 = 1 << 53; diff --git a/crates/engine/src/denoise/model.rs b/crates/engine/src/denoise/model.rs index 28ff953bf..fbd13d79c 100644 --- a/crates/engine/src/denoise/model.rs +++ b/crates/engine/src/denoise/model.rs @@ -4,8 +4,7 @@ use super::gpu::{GPU_CRASHED, GPU_MAX_FALLBACKS, GPU_PARALLEL, GPU_SLOW, GPU_STU use super::settings::{installed_models, read_settings}; #[cfg(feature = "denoise")] use super::state::Pace; -use super::*; - +use super::{Arc, DenoiserManifest, Mutex, Path, TileRunner, Value, json}; /// Stack of the threads that run the model (the runtime recurses through its graph). pub(super) const STACK: usize = 16 << 20; diff --git a/crates/engine/src/denoise/products.rs b/crates/engine/src/denoise/products.rs index 499e8fd6e..bf601b14d 100644 --- a/crates/engine/src/denoise/products.rs +++ b/crates/engine/src/denoise/products.rs @@ -1,6 +1,9 @@ use super::model::{Loader, Model, ModelSlot, RunOn, STACK}; use super::settings::EXT; -use super::*; +use super::{ + Arc, AtomicBool, AtomicUsize, ByteReader, Condvar, Control, DenoiserManifest, HashSet, Hasher128, Layout, Mutex, OnceLock, Ordering, Params, + Path, PathBuf, Photo, PoisonError, RunError, Source, SystemTime, denoise_bayer, product, +}; /// Samples at or above this (white = 1) are clipped: the model leaves them as the camera recorded them. const CLIP: f32 = 0.99; diff --git a/crates/engine/src/denoise/session.rs b/crates/engine/src/denoise/session.rs index 6a452f693..10529d61d 100644 --- a/crates/engine/src/denoise/session.rs +++ b/crates/engine/src/denoise/session.rs @@ -4,7 +4,10 @@ use super::model::STACK; use super::products::{Active, JobSpec, MakeError, Progress, eligible, make_product, product_key, product_path, products_in}; use super::settings::{DIR, SETTINGS_TTL, installed_models, read_settings}; use super::state::{AUTO_LOOK, Failure, MAX_QUEUE, Outcome, Pace, PhotoState, Running}; -use super::*; +use super::{ + Arc, DenoiseSpec, DevelopSettings, HashMap, HashSet, MakeProduct, Mutex, Ordering, PairLoader, Path, PathBuf, Photo, PhotoId, PoisonError, + Selection, Session, Source, Value, json, product, +}; impl Session { /// Where the active model's tiles run (see [`Model::device`]); `{kind: "none"}` while no model is loaded. diff --git a/crates/engine/src/denoise/settings.rs b/crates/engine/src/denoise/settings.rs index f8acd5320..d71c7b074 100644 --- a/crates/engine/src/denoise/settings.rs +++ b/crates/engine/src/denoise/settings.rs @@ -1,6 +1,6 @@ use super::gpu::GPU_SETUP_MARKER; use super::model::RunOn; -use super::*; +use super::{DenoiserManifest, Deserialize, Duration, Path, PathBuf, Serialize, Value}; /// Extension of a product file. pub const EXT: &str = "lcdn"; diff --git a/crates/engine/src/denoise/state.rs b/crates/engine/src/denoise/state.rs index 76a4b6130..37b79cf8b 100644 --- a/crates/engine/src/denoise/state.rs +++ b/crates/engine/src/denoise/state.rs @@ -1,7 +1,7 @@ use super::model::{Loader, default_loader}; use super::products::{Active, Progress}; use super::settings::Settings; -use super::*; +use super::{Arc, Downloads, HashMap, Mutex, PathBuf, PhotoId, Serialize, VecDeque}; /// Most photos waiting in the queue. pub(super) const MAX_QUEUE: usize = 5000; diff --git a/crates/engine/src/export/batch.rs b/crates/engine/src/export/batch.rs index a4bb859e2..b5d45b125 100644 --- a/crates/engine/src/export/batch.rs +++ b/crates/engine/src/export/batch.rs @@ -1,6 +1,6 @@ +use super::ExportOptions; use super::place::Placer; use super::prepare::{Destination, Exported, PreparedExport}; -use super::*; /// Run prepared exports in order: pick each one's path, and hand the bytes (and sidecars) to /// `write`. `exists` tells whether a path is taken. The conflict policy applies to a file and its diff --git a/crates/engine/src/export/encoding.rs b/crates/engine/src/export/encoding.rs index 3cb3be3cb..862c72cd0 100644 --- a/crates/engine/src/export/encoding.rs +++ b/crates/engine/src/export/encoding.rs @@ -1,6 +1,9 @@ use super::options::{ExportFormat, MetadataPolicy, SharpenAmount, SharpenFor}; use super::watermark::{Watermark, draw_watermark, draw_watermark_deep}; -use super::*; +use super::{ + ChromaSubsampling, DateTime, DeepImage, DeepSamples, EncodeImage, EncodeMeta, ExportOptions, Gps, Metadata, NamedSpace, OutputDepth, OutputSpace, + Rgba8, Samples, encode, icc, +}; use std::borrow::Cow; /// Unsharp mask on an 8-bit image (separable box-blur approximation of a small Gaussian). diff --git a/crates/engine/src/export/options.rs b/crates/engine/src/export/options.rs index d1b767be2..5edf078aa 100644 --- a/crates/engine/src/export/options.rs +++ b/crates/engine/src/export/options.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] diff --git a/crates/engine/src/export/params.rs b/crates/engine/src/export/params.rs index 3ef9316da..795d8f367 100644 --- a/crates/engine/src/export/params.rs +++ b/crates/engine/src/export/params.rs @@ -1,7 +1,7 @@ use super::options::{Conflict, DestinationMode, ExportFormat, HdrCurve, MetadataPolicy, Resize, ResizeMode, SharpenAmount, SharpenFor}; use super::prepare::DngCompression; use super::watermark::{Anchor, Watermark}; -use super::*; +use super::{ExportOptions, JxlColour, JxlEffort, JxlOptions, OutputDepth, OutputSpace, TiffCompression}; impl Default for ExportOptions { fn default() -> Self { diff --git a/crates/engine/src/export/place.rs b/crates/engine/src/export/place.rs index f0b1ace61..d223a3a79 100644 --- a/crates/engine/src/export/place.rs +++ b/crates/engine/src/export/place.rs @@ -1,6 +1,6 @@ +use super::ExportOptions; use super::options::{Conflict, DestinationMode}; use super::prepare::{Destination, Exported}; -use super::*; /// Write an exported or rendered file on disk: its folder is created if needed, and the file is /// replaced atomically ([`lightcraft_catalog::safe_file::write_atomic_nosync`]: a temp file diff --git a/crates/engine/src/export/prepare.rs b/crates/engine/src/export/prepare.rs index 1fe988832..0a3dce119 100644 --- a/crates/engine/src/export/prepare.rs +++ b/crates/engine/src/export/prepare.rs @@ -2,7 +2,7 @@ use super::batch::run_batch; use super::encoding::{encode_gain_map_jpeg, encode_rendered, export_metadata, removes_location}; use super::options::ExportFormat; use super::params::apple_photos_unsupported; -use super::*; +use super::{ExportOptions, Metadata, OutputDepth}; /// One exported file. pub struct Exported { diff --git a/crates/engine/src/export/watermark.rs b/crates/engine/src/export/watermark.rs index 68633794b..c074808ea 100644 --- a/crates/engine/src/export/watermark.rs +++ b/crates/engine/src/export/watermark.rs @@ -1,5 +1,5 @@ use super::encoding::srgb8_in; -use super::*; +use super::{DeepImage, DeepSamples, Deserialize, OutputSpace, Rgba8, Serialize}; /// Where a watermark sits. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/engine/src/files/fs.rs b/crates/engine/src/files/fs.rs index dd1f5273c..7012211af 100644 --- a/crates/engine/src/files/fs.rs +++ b/crates/engine/src/files/fs.rs @@ -1,7 +1,7 @@ use super::load::{load_vec, load_vec_pair, load_vec_reduced}; use super::preview::{fit_box, fs_preview_loader, preview_orientation}; use super::probe::probe_bytes; -use super::*; +use super::{Arc, DenoiseSpec, FileLoader, FileProbe, Filter, PairLoader, ReducedLoader, fit}; /// The file's first [`lightcraft_raw::PREVIEW_PREFIX`] bytes (or the whole file when it is shorter), /// which is what every format [`lightcraft_raw::bounded_preview`] covers keeps its pointer in. diff --git a/crates/engine/src/files/load.rs b/crates/engine/src/files/load.rs index 52b01d7f7..428a84eda 100644 --- a/crates/engine/src/files/load.rs +++ b/crates/engine/src/files/load.rs @@ -1,7 +1,7 @@ use super::colour::bin_factor; use super::preview::{fit_box, load_embedded_preview}; use super::probe::preview_reason; -use super::*; +use super::{Arc, CameraColourModel, DenoiseSpec, Filter, HIGHLIGHT_CLIP, Orientation, Rgb32f, SourceInfo, fit, xy_to_temp_tint}; /// Decode a file into a linear Rec.2020 image no larger than `max_edge`, oriented. /// diff --git a/crates/engine/src/files/preview.rs b/crates/engine/src/files/preview.rs index 49430e62a..825167f5c 100644 --- a/crates/engine/src/files/preview.rs +++ b/crates/engine/src/files/preview.rs @@ -1,5 +1,5 @@ use super::fs::{quick_image_srgb, raf_preview_extent, read_prefix, read_prefix_at}; -use super::*; +use super::{Arc, Filter, Orientation, PreviewLoader, Rgb32f, SourceInfo, fit}; /// Orientation for an embedded preview: its own EXIF orientation when it has one, else the raw file's. pub(super) fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation { diff --git a/crates/engine/src/files/probe.rs b/crates/engine/src/files/probe.rs index aff0b884c..12913bc6f 100644 --- a/crates/engine/src/files/probe.rs +++ b/crates/engine/src/files/probe.rs @@ -1,6 +1,6 @@ use super::load::WbScaleOf; use super::preview::embedded_preview_size; -use super::*; +use super::{MediaKind, Meta, Orientation, ProbeInfo}; pub(super) fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { let shutter = m.exposure_time.map(|t| if t >= 1.0 { format!("{t:.0}") } else { format!("1/{:.0}", 1.0 / t) }).unwrap_or_default(); diff --git a/crates/engine/src/import/commit.rs b/crates/engine/src/import/commit.rs index bed8356fb..7d1a69fdd 100644 --- a/crates/engine/src/import/commit.rs +++ b/crates/engine/src/import/commit.rs @@ -2,7 +2,7 @@ use super::defaults::apply_import_defaults; use super::files::copy_into; use super::job::{Prepared, PreparedItem, ReadyFile}; use super::types::{Duplicate, ImportOptions, ImportReport, Kept, Moved, OnDeleted}; -use super::*; +use super::{HashMap, Op, Path, PathBuf, Photo, PhotoId, ProbeInfo, Session, Source}; /// A batch's items in file order while its transfers are pending ([`ImportJob::run_plan`]). #[derive(Default)] diff --git a/crates/engine/src/import/defaults.rs b/crates/engine/src/import/defaults.rs index 7ca5cecc4..6fd3feb00 100644 --- a/crates/engine/src/import/defaults.rs +++ b/crates/engine/src/import/defaults.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Photo, Serialize, Session}; /// The develop settings a photo gets on import: raws start from their as-shot white balance with /// default sharpening / colour noise reduction, and file-embedded lens corrections on (as the diff --git a/crates/engine/src/import/files.rs b/crates/engine/src/import/files.rs index 2cbfc85e7..099794597 100644 --- a/crates/engine/src/import/files.rs +++ b/crates/engine/src/import/files.rs @@ -5,7 +5,7 @@ use super::job::{ImportJob, Prepared, PreparedItem, ReadyFile}; use super::scan::ScanProgress; #[cfg(test)] use super::types::{ImportMode, ImportOptions}; -use super::*; +use super::{Path, PathBuf, ProbeInfo, Session}; /// File extensions offered by import discovery and file pickers (lower case). /// This includes preview-only raws; the file probe decides whether sensor decoding is available. @@ -253,6 +253,7 @@ pub(super) fn copy_into(dir: &Path, src: &str, name: Option<&str>, probe_hash: O #[cfg(test)] mod prepared_tests { use super::*; + use lightcraft_catalog::{Op, Photo, PhotoId, Source}; /// Extensions the picker may offer: every one in [`EXTENSIONS`], minus the /// files this build cannot import (see [`accept_list`]). diff --git a/crates/engine/src/import/job.rs b/crates/engine/src/import/job.rs index 47bc0756f..1730518d5 100644 --- a/crates/engine/src/import/job.rs +++ b/crates/engine/src/import/job.rs @@ -1,7 +1,7 @@ use super::commit::{How, Plan, Slot, Transfer, commit_prepared, name_family, run_transfers, system_clock}; use super::files::{expand, probe_all}; use super::types::{ImportCandidate, ImportMode, ImportOptions, ImportReport, Kept, OnDeleted}; -use super::*; +use super::{HashMap, MediaKind, Path, PathBuf, Photo, PhotoId, ProbeInfo, Session, Source}; /// Import files/folders. See the module docs. pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Result { diff --git a/crates/engine/src/import/scan.rs b/crates/engine/src/import/scan.rs index 61d8d4690..ef925fa05 100644 --- a/crates/engine/src/import/scan.rs +++ b/crates/engine/src/import/scan.rs @@ -1,6 +1,6 @@ use super::files::{expand, probe_all}; use super::types::ImportCandidate; -use super::*; +use super::{HashMap, Path, PathBuf, PhotoId, ProbeInfo, Session, Source}; /// What a [`scan`] needs from the session, so it can run on another thread (a folder on a network /// share can take minutes to read). diff --git a/crates/engine/src/import/types.rs b/crates/engine/src/import/types.rs index 4a848015b..6ad3e2153 100644 --- a/crates/engine/src/import/types.rs +++ b/crates/engine/src/import/types.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, MediaKind, Photo, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum ImportMode { diff --git a/crates/engine/src/media/cache.rs b/crates/engine/src/media/cache.rs index c3d323e38..238870c07 100644 --- a/crates/engine/src/media/cache.rs +++ b/crates/engine/src/media/cache.rs @@ -1,6 +1,6 @@ use super::settings_hash::SettingsHashes; use super::source::{DecodedSource, DenoiseSpec, PairLoader, RENDERED_MEM_BYTES, SourceLevel, SourceRef, THUMB_SOURCE_BYTES}; -use super::*; +use super::{Arc, DevelopSettings, Hasher128, Lru, MediaCache, Photo, PhotoId, PreviewCache, Rgb32f, Source, SourceInfo}; /// The photos that have a cached denoised picture for the denoise model in use. Only memory: nothing here touches the /// file system, so it can be asked on the UI thread. diff --git a/crates/engine/src/media/quick.rs b/crates/engine/src/media/quick.rs index 6098ed4dd..ca399b561 100644 --- a/crates/engine/src/media/quick.rs +++ b/crates/engine/src/media/quick.rs @@ -1,6 +1,6 @@ use super::render::{RenderJob, RenderResult}; use super::source::{PreviewLoader, SourceLevel}; -use super::*; +use super::{Arc, Deserialize, Hash128, Histogram, Photo, PhotoId, PreviewCache, Rendered, Rgba8, Serialize, Source, SourceInfo}; /// Where a [`QuickJob`]'s stand-in image came from. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/engine/src/media/session.rs b/crates/engine/src/media/session.rs index d160b216d..b222739fe 100644 --- a/crates/engine/src/media/session.rs +++ b/crates/engine/src/media/session.rs @@ -1,7 +1,7 @@ use super::quick::{QuickJob, content_key, source_info}; use super::render::{RenderJob, RenderResult}; use super::source::{PreviewLoader, RENDER_CACHE_VERSION, SourceLevel, SourceRef, THUMB_SIZES, face_edge, source_edge_needed, thumb_bucket}; -use super::*; +use super::{Arc, DevelopSettings, Hash128, Hasher128, MediaKind, Op, Photo, PhotoId, RenderRequest, Rendered, Rgb32f, Source, SourceInfo}; impl crate::Session { /// Build a render job for `id` fitting `max_w × max_h`. `before` renders the unedited look. diff --git a/crates/engine/src/media/settings_hash.rs b/crates/engine/src/media/settings_hash.rs index 5d9c08b93..802918fb9 100644 --- a/crates/engine/src/media/settings_hash.rs +++ b/crates/engine/src/media/settings_hash.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, DevelopSettings, HashMap, PhotoId, VecDeque, Weak}; pub(super) const SETTINGS_HASH_ENTRIES: usize = 1024; diff --git a/crates/engine/src/media/source.rs b/crates/engine/src/media/source.rs index 51c54c60a..8f6a9c28c 100644 --- a/crates/engine/src/media/source.rs +++ b/crates/engine/src/media/source.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Deserialize, DevelopSettings, MediaKind, Photo, Rgb32f, Rgba8, Serialize, SourceInfo}; /// Bump when the pipeline's output changes, to invalidate cached thumbnails. pub const RENDER_CACHE_VERSION: u64 = 70; diff --git a/crates/engine/src/rename/fs.rs b/crates/engine/src/rename/fs.rs index 352140b38..71de1a784 100644 --- a/crates/engine/src/rename/fs.rs +++ b/crates/engine/src/rename/fs.rs @@ -1,5 +1,5 @@ use super::sidecar::PacketVersion; -use super::*; +use super::{Path, PathBuf, SidecarNaming}; /// Every sidecar that may belong to `path` (both naming conventions, `.xmp` and `.XMP`). pub(super) fn sidecars(path: &str) -> Vec<(PathBuf, SidecarNaming, bool)> { diff --git a/crates/engine/src/rename/moves.rs b/crates/engine/src/rename/moves.rs index 6f7c245c9..07d7fdfc3 100644 --- a/crates/engine/src/rename/moves.rs +++ b/crates/engine/src/rename/moves.rs @@ -1,6 +1,6 @@ use super::fs::{MoveFs, RealFs, folded_path, is_respelling, move_one, sidecars}; use super::sidecar::{RetainedPacket, SidecarMove, advance_packets, note_companions, packet_aliases}; -use super::*; +use super::{HashMap, HashSet, Op, Path, PathBuf, SidecarNaming, Source}; /// Why [`move_file`] failed, and what it left behind. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/engine/src/rename/plan.rs b/crates/engine/src/rename/plan.rs index 331297ba4..4c9620bf1 100644 --- a/crates/engine/src/rename/plan.rs +++ b/crates/engine/src/rename/plan.rs @@ -2,7 +2,7 @@ use super::fs::{MoveFs, RealFs, folded_path}; use super::moves::{BatchMoveError, move_all, set_file_ops_to}; use super::sidecar::{PacketVersion, retry_sidecars, retry_sidecars_for_paths}; use super::tokens::{expand, split_ext}; -use super::*; +use super::{EngineError, HashMap, HashSet, Op, Path, PathBuf, Photo, PhotoId, RenamePlan, Result, Session, Source}; /// The photos among `ids` a rename handles, in order: photos sharing one file (virtual copies) /// rename once. No file-system calls. diff --git a/crates/engine/src/rename/sidecar.rs b/crates/engine/src/rename/sidecar.rs index 24ebcd3f9..85f9302d2 100644 --- a/crates/engine/src/rename/sidecar.rs +++ b/crates/engine/src/rename/sidecar.rs @@ -1,5 +1,5 @@ use super::fs::{MoveFs, is_respelling, move_one}; -use super::*; +use super::{Path, PathBuf}; /// An unfinished sidecar operation. Keep its exact paths and any recorded copy requirement: the photo may /// already have followed its new path, so a retry cannot infer this work from the catalog. diff --git a/crates/engine/src/rename/tokens.rs b/crates/engine/src/rename/tokens.rs index 3490e272b..ee15836e8 100644 --- a/crates/engine/src/rename/tokens.rs +++ b/crates/engine/src/rename/tokens.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{MONTHS, Path, Photo, PhotoId, Serialize, Source}; pub(super) fn split_ext(name: &str) -> (&str, &str) { match name.rsplit_once('.') { diff --git a/xtask/src/ci.rs b/xtask/src/ci.rs index 61e179ce0..bc3a9b706 100644 --- a/xtask/src/ci.rs +++ b/xtask/src/ci.rs @@ -1,7 +1,7 @@ use super::layers::cmd_layers; use super::raw_samples::RAW_SAMPLES; use super::wasm::{cmd_wasm, cmd_wasm_for}; -use super::*; +use super::{Command, Path, PathBuf, USAGE, assets, cargo, ci_lock, ci_quick, ci_report, metadata, parity, root, run}; /// CI build jobs: one per 1.5 GB of RAM available when CI starts, at most one per CPU, 4 when it /// is unknown. With line-table debuginfo, 4 jobs measured about 3 GB of RAM in use and 2 jobs diff --git a/xtask/src/corpus.rs b/xtask/src/corpus.rs index 67de8c1ff..434d48b27 100644 --- a/xtask/src/corpus.rs +++ b/xtask/src/corpus.rs @@ -1,5 +1,5 @@ use super::raw_samples::RAW_SAMPLES; -use super::*; +use super::{Command, Path, root, run}; pub(crate) fn cmd_corpus(download: bool) -> Result<(), String> { let corpus = root().join("corpus"); diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 13756002d..2a881155b 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -3,7 +3,7 @@ //! The rule engine works on a small, metadata-independent model so it can be //! unit-tested; `from_metadata` builds that model from `cargo metadata`. -use super::*; +use super::{layers, metadata}; use serde_json::Value; /// Where a workspace crate sits. diff --git a/xtask/src/wasm.rs b/xtask/src/wasm.rs index 4f16b2d75..4330769a9 100644 --- a/xtask/src/wasm.rs +++ b/xtask/src/wasm.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{cargo, layers, metadata, run}; /// Workspace packages that must build for wasm32: all L0–L5 crates plus /// the egui shell and the web app. From ca69e74b62c819e4dee07fb50eb674ef39b8557a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 18:02:32 +0000 Subject: [PATCH 20/39] fix(raw): check opcode byte counts without overflow --- crates/raw/src/opcodes.rs | 4 ++-- crates/raw/src/opcodes/tests.rs | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/raw/src/opcodes.rs b/crates/raw/src/opcodes.rs index f7750aaac..4d3e463b0 100644 --- a/crates/raw/src/opcodes.rs +++ b/crates/raw/src/opcodes.rs @@ -381,7 +381,7 @@ fn parse_gain_map(area: Area, reader: &mut Rd<'_>) -> Option { let origin = [reader.finite_f64()?, reader.finite_f64()?]; let map_planes = reader.u32()?; let count = (points_v as usize).checked_mul(points_h as usize)?.checked_mul(map_planes as usize)?; - if count == 0 || count * 4 > reader.remaining() { + if count == 0 || count > reader.remaining() / 4 { return None; } let gains = (0..count).map(|_| reader.f32()).collect::>()?; @@ -390,7 +390,7 @@ fn parse_gain_map(area: Area, reader: &mut Rd<'_>) -> Option { fn parse_delta_scale(id: u32, area: Area, reader: &mut Rd<'_>) -> Option { let count = reader.u32()? as usize; - if count * 4 > reader.remaining() { + if count > reader.remaining() / 4 { return None; } let values: Vec = (0..count).map(|_| reader.f32()).collect::>()?; diff --git a/crates/raw/src/opcodes/tests.rs b/crates/raw/src/opcodes/tests.rs index e63a0bf56..d1be999e9 100644 --- a/crates/raw/src/opcodes/tests.rs +++ b/crates/raw/src/opcodes/tests.rs @@ -1,5 +1,19 @@ use super::*; +#[test] +fn reject_opcode_counts_whose_bytes_exceed_the_remaining_data() { + let mut gain_map = Vec::new(); + for value in [u32::MAX, 1] { + gain_map.extend_from_slice(&value.to_be_bytes()); + } + gain_map.extend(be(&[1.0, 1.0, 0.0, 0.0])); + gain_map.extend_from_slice(&1u32.to_be_bytes()); + assert!(parse_gain_map(Area::default(), &mut Rd { d: &gain_map, p: 0 }).is_none()); + + let delta_count = u32::MAX.to_be_bytes(); + assert!(parse_delta_scale(10, Area::default(), &mut Rd { d: &delta_count, p: 0 }).is_none()); +} + pub(crate) struct ListWriter(pub Vec, pub u32); impl ListWriter { From b298c79a7d85d47773675ace2e229bcc49da92c6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 18:04:04 +0000 Subject: [PATCH 21/39] docs: correct clippy indexing guidance --- AGENTS.md | 2 +- CLAUDE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 959d2808f..31dfd70f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md` - **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native. - **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix. -- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic/indexing in tests only, and every production crate +- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic in tests only; indexing is not denied. Every production crate root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Not `[workspace.lints]`: those would also hit integration tests, examples and benches. New crates start with the attribute. diff --git a/CLAUDE.md b/CLAUDE.md index 1ff3defb0..5b116781a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md` - **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native. - **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix. -- Enforced by clippy: root `clippy.toml` keeps unwrap/expect/panic/indexing allowances scoped to tests. The workspace +- Enforced by clippy: root `clippy.toml` keeps unwrap/expect/panic allowances scoped to tests; indexing is not denied. The workspace denies `cognitive_complexity`, `too_many_arguments` and `wildcard_imports`; members inherit them with `[lints] workspace = true`, and `sysmem` repeats those denies in its local lint table. Every production crate root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, From eeb63636e80d6e9f44cf9133ce70e4ebb47cda9d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 18:12:55 +0000 Subject: [PATCH 22/39] docs: clarify crate lint attribute placement --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5b116781a..7005cac3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,8 +57,8 @@ of it. Full standard: `../craftrules/standards/never-crash.md` `[lints] workspace = true`, and `sysmem` repeats those denies in its local lint table. Every production crate root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Every new workspace crate must include - `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`, and new production crate roots start with - the attribute. + `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`, and new production crate roots include + the attribute before modules or other crate items; crate-level `//!` documentation may precede it. ## Non-negotiables - **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…). From 5b5ddc39049ba7fb5c9532cd6f1f3c33aa4fc83a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 19:49:11 +0000 Subject: [PATCH 23/39] fix(engine): import denoise model and gpu names explicitly --- crates/engine/src/denoise/gpu.rs | 2 +- crates/engine/src/denoise/model.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/denoise/gpu.rs b/crates/engine/src/denoise/gpu.rs index 1577b4edc..a2a493fab 100644 --- a/crates/engine/src/denoise/gpu.rs +++ b/crates/engine/src/denoise/gpu.rs @@ -1,7 +1,7 @@ #[cfg(feature = "denoise")] use super::model::{GpuSide, STACK}; #[cfg(feature = "denoise")] -use super::*; +use super::{Arc, Duration, Mutex, Path, PathBuf, PoisonError, SystemTime, TileRunner}; /// Tiles run at once on a graphics card: enough to keep it fed while others are packed and blended. #[cfg(feature = "denoise")] diff --git a/crates/engine/src/denoise/model.rs b/crates/engine/src/denoise/model.rs index fbd13d79c..b9f75023e 100644 --- a/crates/engine/src/denoise/model.rs +++ b/crates/engine/src/denoise/model.rs @@ -4,6 +4,8 @@ use super::gpu::{GPU_CRASHED, GPU_MAX_FALLBACKS, GPU_PARALLEL, GPU_SLOW, GPU_STU use super::settings::{installed_models, read_settings}; #[cfg(feature = "denoise")] use super::state::Pace; +#[cfg(feature = "denoise")] +use super::{AtomicBool, AtomicUsize, Ordering, OnceLock, PathBuf, PoisonError, RunError}; use super::{Arc, DenoiserManifest, Mutex, Path, TileRunner, Value, json}; /// Stack of the threads that run the model (the runtime recurses through its graph). pub(super) const STACK: usize = 16 << 20; From 6774d1419f694f138956a87e0a6366c72969e287 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 19:56:22 +0000 Subject: [PATCH 24/39] refactor(ui-egui): replace wildcard imports with explicit lists --- crates/engine/src/denoise/model.rs | 4 ++-- crates/ui-egui/src/app.rs | 8 ++++++- crates/ui-egui/src/app/control.rs | 2 +- crates/ui-egui/src/app/frame.rs | 5 +++- crates/ui-egui/src/app/input.rs | 24 +++++++++---------- crates/ui-egui/src/app/prefs.rs | 2 +- crates/ui-egui/src/app/screenshot.rs | 5 +++- crates/ui-egui/src/caches.rs | 2 +- crates/ui-egui/src/dialogs/confirm.rs | 2 +- crates/ui-egui/src/dialogs/cull.rs | 2 +- crates/ui-egui/src/dialogs/export.rs | 2 +- crates/ui-egui/src/dialogs/frame.rs | 2 +- crates/ui-egui/src/dialogs/import/body.rs | 2 +- crates/ui-egui/src/dialogs/import/scan.rs | 2 +- crates/ui-egui/src/dialogs/import/tags.rs | 2 +- crates/ui-egui/src/dialogs/import/task.rs | 2 +- crates/ui-egui/src/dialogs/rename.rs | 2 +- crates/ui-egui/src/dialogs/sam_model.rs | 2 +- .../src/dialogs/settings/appearance.rs | 2 +- .../ui-egui/src/dialogs/settings/display.rs | 2 +- .../ui-egui/src/dialogs/settings/general.rs | 2 +- crates/ui-egui/src/dialogs/settings/import.rs | 2 +- .../ui-egui/src/dialogs/settings/libraries.rs | 2 +- .../src/dialogs/settings/performance.rs | 2 +- .../ui-egui/src/dialogs/settings/slideshow.rs | 2 +- .../ui-egui/src/dialogs/settings/widgets.rs | 2 +- .../ui-egui/src/dialogs/slideshow_export.rs | 2 +- crates/ui-egui/src/menus/files.rs | 2 +- crates/ui-egui/src/menus/run.rs | 2 +- crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/panels/detail/crop.rs | 4 +++- crates/ui-egui/src/panels/detail/filmstrip.rs | 2 +- crates/ui-egui/src/panels/detail/geometry.rs | 4 ++-- crates/ui-egui/src/panels/detail/info.rs | 2 +- crates/ui-egui/src/panels/detail/masks.rs | 5 +++- crates/ui-egui/src/panels/detail/retouch.rs | 2 +- crates/ui-egui/src/panels/detail/window.rs | 2 +- crates/ui-egui/src/panels/edit/curve.rs | 5 +++- crates/ui-egui/src/panels/edit/grading.rs | 5 +++- crates/ui-egui/src/panels/edit/histogram.rs | 5 +++- crates/ui-egui/src/panels/edit/mixer.rs | 2 +- crates/ui-egui/src/panels/edit/sliders.rs | 5 +++- crates/ui-egui/src/panels/left/albums.rs | 2 +- crates/ui-egui/src/panels/left/dates.rs | 2 +- crates/ui-egui/src/panels/left/folders.rs | 2 +- crates/ui-egui/src/panels/left/keywords.rs | 2 +- crates/ui-egui/src/panels/left/local.rs | 2 +- crates/ui-egui/src/panels/left/rows.rs | 2 +- crates/ui-egui/src/panels/left/sections.rs | 2 +- crates/ui-egui/src/panels/right/crop.rs | 2 +- crates/ui-egui/src/panels/right/history.rs | 2 +- crates/ui-egui/src/panels/right/info.rs | 2 +- crates/ui-egui/src/panels/right/keywords.rs | 2 +- crates/ui-egui/src/services.rs | 2 +- crates/ui-egui/src/state/dialog.rs | 2 +- crates/ui-egui/src/state/settings.rs | 2 +- crates/ui-egui/src/state/view.rs | 2 +- 57 files changed, 99 insertions(+), 70 deletions(-) diff --git a/crates/engine/src/denoise/model.rs b/crates/engine/src/denoise/model.rs index b9f75023e..c90641574 100644 --- a/crates/engine/src/denoise/model.rs +++ b/crates/engine/src/denoise/model.rs @@ -4,9 +4,9 @@ use super::gpu::{GPU_CRASHED, GPU_MAX_FALLBACKS, GPU_PARALLEL, GPU_SLOW, GPU_STU use super::settings::{installed_models, read_settings}; #[cfg(feature = "denoise")] use super::state::Pace; -#[cfg(feature = "denoise")] -use super::{AtomicBool, AtomicUsize, Ordering, OnceLock, PathBuf, PoisonError, RunError}; use super::{Arc, DenoiserManifest, Mutex, Path, TileRunner, Value, json}; +#[cfg(feature = "denoise")] +use super::{AtomicBool, AtomicUsize, OnceLock, Ordering, PathBuf, PoisonError, RunError}; /// Stack of the threads that run the model (the runtime recurses through its graph). pub(super) const STACK: usize = 16 << 20; diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 98f8458c9..fa10210f0 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -1,7 +1,13 @@ use super::clock::{AppClock, monotonic_ms}; use super::services::Services; use super::state::{GestureFrame, InjectedDrop, InjectedPress}; -use super::*; +use super::{ + AiModel, Caches, CommandError, ControlRequest, ControlResponse, ErrorKind, GESTURE_TIMEOUT_MS, LightcraftApp, MAX_PENDING_GESTURES, + MAX_PENDING_SHOTS, MAX_SHOT_BYTES, MAX_SHOT_TOTAL_BYTES, MAX_TEXTURE_SIDE, PendingGesture, PendingShot, Perf, Receiver, SCREEN_PROFILE, + SCREENSHOT_FALLBACK_MS, SCREENSHOT_SETTLE_MS, SHOT_FORCE_BUSY_MS, SHOT_SETTLE_FRAMES, SHOT_SETTLE_MS, Sender, Session, ShotGeometry, + TOAST_SECONDS, UiState, Value, docking, drop_overlay, export_task, focus_scan, frame_rate, headless, i18n, import, lightroom_import, menus, + merge, model_setup, panels, pick, recheck, render, shortcuts, slideshow_export, state, sync, tasks, theme, updates_ui, widgets, +}; mod control; mod frame; diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 00b66e4b0..525c5419c 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{ControlResponse, GESTURE_TIMEOUT_MS, GestureFrame, LightcraftApp, MAX_PENDING_GESTURES, PendingGesture, Sender, Value}; use crate::control; impl LightcraftApp { diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index efae98a9b..6e92091c7 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + LightcraftApp, docking, drop_overlay, export_task, focus_scan, import, lightroom_import, merge, model_setup, monotonic_ms, panels, pick, recheck, + shortcuts, slideshow_export, state, sync, tasks, theme, updates_ui, widgets, +}; impl LightcraftApp { /// Install/refresh fonts once and watch the device and availability caches. Setup, not input. diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index f94108ce0..35b7991b4 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{GESTURE_TIMEOUT_MS, InjectedDrop, InjectedPress, LightcraftApp, drop_overlay, import, panels}; impl LightcraftApp { /// Preserve the currently queued handles while a prompt or blocking library window owns @@ -262,7 +262,7 @@ mod drop_tests { fn photo_sidecars_are_not_presets() { let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); let dropped = v(&["/a/IMG_1.CR2", "/a/IMG_1.xmp", "/a/IMG_2.jpg", "/a/IMG_2.jpg.XMP", "/a/Look.xmp", "/b/IMG_1.xmp"]); - let (presets, photos) = super::split_presets(dropped); + let (presets, photos) = crate::split_presets(dropped); assert_eq!(photos, v(&["/a/IMG_1.CR2", "/a/IMG_2.jpg"])); assert_eq!(presets, v(&["/a/Look.xmp", "/b/IMG_1.xmp"])); } @@ -271,31 +271,31 @@ mod drop_tests { fn detection_results_are_summarised_in_words() { let r = |counts: &[usize]| serde_json::json!({"photos": counts.iter().map(|n| serde_json::json!({"faces": vec![0; *n]})).collect::>()}); - assert_eq!(super::detect_summary(&r(&[0])), "No faces found"); - assert_eq!(super::detect_summary(&r(&[1])), "Found 1 face"); - assert_eq!(super::detect_summary(&r(&[3])), "Found 3 faces"); - assert_eq!(super::detect_summary(&r(&[0, 0])), "No faces found in 2 photos"); - assert_eq!(super::detect_summary(&r(&[2, 0, 1])), "Found 3 faces in 2 photos"); - assert_eq!(super::detect_summary(&serde_json::json!({})), "No faces found in 0 photos"); + assert_eq!(crate::app::detect_summary(&r(&[0])), "No faces found"); + assert_eq!(crate::app::detect_summary(&r(&[1])), "Found 1 face"); + assert_eq!(crate::app::detect_summary(&r(&[3])), "Found 3 faces"); + assert_eq!(crate::app::detect_summary(&r(&[0, 0])), "No faces found in 2 photos"); + assert_eq!(crate::app::detect_summary(&r(&[2, 0, 1])), "Found 3 faces in 2 photos"); + assert_eq!(crate::app::detect_summary(&serde_json::json!({})), "No faces found in 0 photos"); } #[test] fn dropped_face_models_are_told_apart() { for p in ["/a/model.onnx", "/a/dir/M.ONNX"] { - assert!(super::is_model_file(p), "{p}"); + assert!(crate::is_model_file(p), "{p}"); } for p in ["/a/model.onnx.jpg", "/a/onnx", "/a/b.png"] { - assert!(!super::is_model_file(p), "{p}"); + assert!(!crate::is_model_file(p), "{p}"); } } #[test] fn dropped_presets_are_told_apart_from_photos() { for p in ["/a/Look.lrtemplate", "/a/b.XMP", "/a/pack.zip", "/a/x.lcpreset", "/a/Magic Hour.mplumpack", "/a/Pop.lmp", "/a/Bundle.LMP"] { - assert!(super::is_preset_file(p), "{p}"); + assert!(crate::is_preset_file(p), "{p}"); } for p in ["/a/IMG_1.CR2", "/a/b.dng", "/a/c.jpg", "/a/folder"] { - assert!(!super::is_preset_file(p), "{p}"); + assert!(!crate::is_preset_file(p), "{p}"); } } } diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index a85a5f751..3dcafeb14 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LightcraftApp, SCREEN_PROFILE, theme}; impl LightcraftApp { /// The system's appearance: the host's answer first, then egui's (none: unknown). diff --git a/crates/ui-egui/src/app/screenshot.rs b/crates/ui-egui/src/app/screenshot.rs index c2de3aae6..79b463995 100644 --- a/crates/ui-egui/src/app/screenshot.rs +++ b/crates/ui-egui/src/app/screenshot.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + ControlResponse, LightcraftApp, MAX_PENDING_SHOTS, MAX_SHOT_BYTES, MAX_SHOT_TOTAL_BYTES, PendingShot, SCREENSHOT_FALLBACK_MS, + SCREENSHOT_SETTLE_MS, SHOT_FORCE_BUSY_MS, SHOT_SETTLE_FRAMES, SHOT_SETTLE_MS, Sender, ShotGeometry, headless, +}; use crate::control; impl LightcraftApp { diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index 0fb5a4b2c..93fe55184 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Caches, album_picker, i18n, panels}; /// The left panel's counts. #[derive(Clone, Copy, Debug, Default)] diff --git a/crates/ui-egui/src/dialogs/confirm.rs b/crates/ui-egui/src/dialogs/confirm.rs index 196018e55..6b8998c33 100644 --- a/crates/ui-egui/src/dialogs/confirm.rs +++ b/crates/ui-egui/src/dialogs/confirm.rs @@ -1,6 +1,6 @@ use super::cull::cull_params; use super::export::export_dialog_params; -use super::*; +use super::{Dialog, LightcraftApp, json}; /// Runs a command that creates an album and asks the Albums tree to open the folders down to it. fn created_in(app: &mut LightcraftApp, command: &str, params: serde_json::Value) -> Result { diff --git a/crates/ui-egui/src/dialogs/cull.rs b/crates/ui-egui/src/dialogs/cull.rs index 2c3d6497a..a43276a64 100644 --- a/crates/ui-egui/src/dialogs/cull.rs +++ b/crates/ui-egui/src/dialogs/cull.rs @@ -1,4 +1,4 @@ -use super::*; +use super::json; /// Apply a dialog's action (also used by `ui.dialog.confirm`). /// What [`fmt_gap`] writes, read back: `90`, `90 s`, `1 min 30 s`, `2 h 5 min`, `1 day` diff --git a/crates/ui-egui/src/dialogs/export.rs b/crates/ui-egui/src/dialogs/export.rs index 56292605c..ce9cffe77 100644 --- a/crates/ui-egui/src/dialogs/export.rs +++ b/crates/ui-egui/src/dialogs/export.rs @@ -1,5 +1,5 @@ use super::frame::{field, num}; -use super::*; +use super::{ControlSpec, LightcraftApp, Section, SettingsGroup, Tokens, Track}; /// The Export dialog's watermark preview: the first exported photo, as wide as the dialog whatever /// its shape, with the watermark drawn on it as the file will have it at size `resize` (`None` = diff --git a/crates/ui-egui/src/dialogs/frame.rs b/crates/ui-egui/src/dialogs/frame.rs index 3d8eb4ef8..007c733cb 100644 --- a/crates/ui-egui/src/dialogs/frame.rs +++ b/crates/ui-egui/src/dialogs/frame.rs @@ -1,5 +1,5 @@ use super::export::LABEL_W; -use super::*; +use super::{BACKDROP_SHOWN, ControlSpec, Dialog, LightcraftApp, Tokens}; /// A click on the dimmed backdrop around the dialog: it closes the dialog like Esc. Pressed and /// released outside the window, so a slider dragged past its edge doesn't close it; a press while a diff --git a/crates/ui-egui/src/dialogs/import/body.rs b/crates/ui-egui/src/dialogs/import/body.rs index 9ba470660..8f82ae51d 100644 --- a/crates/ui-egui/src/dialogs/import/body.rs +++ b/crates/ui-egui/src/dialogs/import/body.rs @@ -1,5 +1,5 @@ use super::tags::{tag_field_width, tag_help, tag_toggle, unknown_tags_warning}; -use super::*; +use super::{Align2, Color32, ImportDialog, LightcraftApp, Rect, Sense, Slot, Stroke, StrokeKind, Tokens, Vec2, pos2, register, vec2}; /// The review dialog's first size (points). It can be resized; the photo grid takes the height. pub(crate) const DIALOG_SIZE: [f32; 2] = [960.0, 720.0]; diff --git a/crates/ui-egui/src/dialogs/import/scan.rs b/crates/ui-egui/src/dialogs/import/scan.rs index a930af00c..67b1fa176 100644 --- a/crates/ui-egui/src/dialogs/import/scan.rs +++ b/crates/ui-egui/src/dialogs/import/scan.rs @@ -1,5 +1,5 @@ use super::task::ImportTask; -use super::*; +use super::{ImportDialog, LightcraftApp, Ordering, ScanInput, ScanOutput, ScanProgress, Value, json, scan_with}; /// A folder scan running on a worker thread (a network share can take minutes to read; the /// window must keep answering meanwhile). diff --git a/crates/ui-egui/src/dialogs/import/tags.rs b/crates/ui-egui/src/dialogs/import/tags.rs index 770bb5b30..1ae10ca02 100644 --- a/crates/ui-egui/src/dialogs/import/tags.rs +++ b/crates/ui-egui/src/dialogs/import/tags.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Tokens, register}; /// Reserve the actual translated tag button width beside a template field. pub(crate) fn tag_field_width(ui: &egui::Ui) -> f32 { diff --git a/crates/ui-egui/src/dialogs/import/task.rs b/crates/ui-egui/src/dialogs/import/task.rs index bcef55b11..c41a941a3 100644 --- a/crates/ui-egui/src/dialogs/import/task.rs +++ b/crates/ui-egui/src/dialogs/import/task.rs @@ -1,5 +1,5 @@ use super::scan::{busy_synchronizing, running}; -use super::*; +use super::{ImportDialog, ImportUprightMode, ImportWarning, ImportWarningCode, LightcraftApp, Ordering, Value, json}; /// Files per batch in the browser build (each batch joins the catalog as it is ready, so the /// progress window updates); the desktop's worker uses [`lightcraft_engine::import::batch_size`]. diff --git a/crates/ui-egui/src/dialogs/rename.rs b/crates/ui-egui/src/dialogs/rename.rs index f178ba6eb..8632daf41 100644 --- a/crates/ui-egui/src/dialogs/rename.rs +++ b/crates/ui-egui/src/dialogs/rename.rs @@ -1,4 +1,4 @@ -use super::*; +use super::LightcraftApp; /// Rows the Rename dialog previews. pub(super) const RENAME_PREVIEW_ROWS: usize = 6; diff --git a/crates/ui-egui/src/dialogs/sam_model.rs b/crates/ui-egui/src/dialogs/sam_model.rs index fa578440b..fafa91ceb 100644 --- a/crates/ui-egui/src/dialogs/sam_model.rs +++ b/crates/ui-egui/src/dialogs/sam_model.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LightcraftApp, Tokens, json}; /// No SAM 3 model, no download running and nowhere to download it from: installing it by hand is /// all the dialog can offer. diff --git a/crates/ui-egui/src/dialogs/settings/appearance.rs b/crates/ui-egui/src/dialogs/settings/appearance.rs index 51159df6b..571db16fc 100644 --- a/crates/ui-egui/src/dialogs/settings/appearance.rs +++ b/crates/ui-egui/src/dialogs/settings/appearance.rs @@ -1,5 +1,5 @@ use super::widgets::{choices, heading}; -use super::*; +use super::{RichText, Tokens, register}; /// Appearance Mode (`button:settingsAppearance-{0,1,2}`: Auto, Dark, Light) above the light and /// dark theme cards (`radio:theme-{id}`), each with a preview of the chosen theme. The card in diff --git a/crates/ui-egui/src/dialogs/settings/display.rs b/crates/ui-egui/src/dialogs/settings/display.rs index a54b05ced..a5b1974d2 100644 --- a/crates/ui-egui/src/dialogs/settings/display.rs +++ b/crates/ui-egui/src/dialogs/settings/display.rs @@ -1,6 +1,6 @@ use super::appearance::appearance; use super::widgets::{check, choices, heading, hint, path_label, row}; -use super::*; +use super::{GridBadges, LightcraftApp, RichText, Tokens, Value, json, register}; // ---------------------------------------------------------------------------------- Interface diff --git a/crates/ui-egui/src/dialogs/settings/general.rs b/crates/ui-egui/src/dialogs/settings/general.rs index 21b94f150..85866b5fa 100644 --- a/crates/ui-egui/src/dialogs/settings/general.rs +++ b/crates/ui-egui/src/dialogs/settings/general.rs @@ -1,5 +1,5 @@ use super::widgets::{check, choices, heading, hint, path_label, row}; -use super::*; +use super::{LightcraftApp, RichText, StartupView, Tokens, json, register}; // ------------------------------------------------------------------------------------- General diff --git a/crates/ui-egui/src/dialogs/settings/import.rs b/crates/ui-egui/src/dialogs/settings/import.rs index c7272d366..97f34d6ab 100644 --- a/crates/ui-egui/src/dialogs/settings/import.rs +++ b/crates/ui-egui/src/dialogs/settings/import.rs @@ -1,6 +1,6 @@ use super::general::{cameras, preset_combo}; use super::widgets::{button_width, check, choices, heading, hint, path_label, row}; -use super::*; +use super::{LightcraftApp, RichText, Tokens, json, register}; pub(super) fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let d = app.session.import_defaults.clone(); diff --git a/crates/ui-egui/src/dialogs/settings/libraries.rs b/crates/ui-egui/src/dialogs/settings/libraries.rs index 389f79f11..8e41e8dd4 100644 --- a/crates/ui-egui/src/dialogs/settings/libraries.rs +++ b/crates/ui-egui/src/dialogs/settings/libraries.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LightcraftApp, Value, json}; // ------------------------------------------------------------------------------- Open Library… diff --git a/crates/ui-egui/src/dialogs/settings/performance.rs b/crates/ui-egui/src/dialogs/settings/performance.rs index daf2fa435..e65402c20 100644 --- a/crates/ui-egui/src/dialogs/settings/performance.rs +++ b/crates/ui-egui/src/dialogs/settings/performance.rs @@ -1,7 +1,7 @@ use super::widgets::{check, choices, heading, hint, row}; #[cfg(not(target_arch = "wasm32"))] use super::widgets::{label_width, path_label}; -use super::*; +use super::{CACHE_SIZES, LABEL_W, LightcraftApp, PREVIEW_LIMITS, RichText, Tokens, Value, json, register}; // --------------------------------------------------------------------------------- Performance diff --git a/crates/ui-egui/src/dialogs/settings/slideshow.rs b/crates/ui-egui/src/dialogs/settings/slideshow.rs index b67427c40..a11bbfff6 100644 --- a/crates/ui-egui/src/dialogs/settings/slideshow.rs +++ b/crates/ui-egui/src/dialogs/settings/slideshow.rs @@ -1,6 +1,6 @@ use super::display::duration_slider; use super::widgets::{check, choices, heading, hint, row}; -use super::*; +use super::{LightcraftApp, Tokens, Value, json, register}; /// The Slideshow tab: the slide preview, then the settings, in one column the width of the page /// (the page scrolls; a column beside the preview would be wider than the window). Without diff --git a/crates/ui-egui/src/dialogs/settings/widgets.rs b/crates/ui-egui/src/dialogs/settings/widgets.rs index 3aea00b44..7607b1251 100644 --- a/crates/ui-egui/src/dialogs/settings/widgets.rs +++ b/crates/ui-egui/src/dialogs/settings/widgets.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LABEL_W, RichText, Tokens, register}; /// `path` shortened to fit `max` (as `width` measures it) by dropping leading folders, after /// either kind of separator, so that its end stays (`…/Pictures/Library`, diff --git a/crates/ui-egui/src/dialogs/slideshow_export.rs b/crates/ui-egui/src/dialogs/slideshow_export.rs index 7a898b5db..0218d613e 100644 --- a/crates/ui-egui/src/dialogs/slideshow_export.rs +++ b/crates/ui-egui/src/dialogs/slideshow_export.rs @@ -5,8 +5,8 @@ use serde_json::{Value, json}; +use super::Tokens; use super::frame::{choices, field}; -use super::*; use crate::LightcraftApp; use crate::pick::{PickKind, PickRequest, Picked}; diff --git a/crates/ui-egui/src/menus/files.rs b/crates/ui-egui/src/menus/files.rs index 52389d930..d75282e57 100644 --- a/crates/ui-egui/src/menus/files.rs +++ b/crates/ui-egui/src/menus/files.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, LightcraftApp, Value, json}; /// The Delete key in an album: a normal album loses the selected photos (`album.removePhotos`), /// which stay in the library and their folder; a smart album keeps them (its rules decide) and diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index c550ff0d4..9977affac 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -1,6 +1,6 @@ use super::files::{edit_in, open_log_folder, open_original_in, show_in_finder, show_in_folders}; use super::ui_commands::{UI_COMMANDS, language_from_command}; -use super::*; +use super::{BeforeAfter, Dialog, LightcraftApp, PickRequest, Picked, RightPanel, Value, ViewMode, Zoom, json, thumb_size_view}; fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str) { if app.ui.right == p && crate::docking::exposed(app, crate::docking::Panel::Tools) { diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 7236b5528..2759d2197 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1,4 +1,4 @@ -use super::*; +use super::UiCommand; /// The "Edit → Language" entries, one per language in [`crate::i18n::Locale::ALL`], so a language /// has a matching menu command (also listed through the control channel). Tests check coverage. diff --git a/crates/ui-egui/src/panels/detail/crop.rs b/crates/ui-egui/src/panels/detail/crop.rs index 295b8f819..f88b23fdd 100644 --- a/crates/ui-egui/src/panels/detail/crop.rs +++ b/crates/ui-egui/src/panels/detail/crop.rs @@ -1,5 +1,7 @@ use super::geometry::CanvasMap; -use super::*; +use super::{ + Affine, Color32, DevelopSettings, Frame, Gesture, LightcraftApp, PhotoId, Point, Pos2, Rect, Stroke, Tokens, json, pos2, register, vec2, +}; // ------------------------------------------------------------------------ crop diff --git a/crates/ui-egui/src/panels/detail/filmstrip.rs b/crates/ui-egui/src/panels/detail/filmstrip.rs index c39fc4442..4a0de28cf 100644 --- a/crates/ui-egui/src/panels/detail/filmstrip.rs +++ b/crates/ui-egui/src/panels/detail/filmstrip.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align2, Color32, LightcraftApp, PhotoId, Pos2, Rect, Sense, Slot, Stroke, StrokeKind, Tokens, json, pos2, register, vec2}; /// Size of the Navigator mini map (points). const NAV_W: f32 = 180.0; diff --git a/crates/ui-egui/src/panels/detail/geometry.rs b/crates/ui-egui/src/panels/detail/geometry.rs index 6424dabd8..d679010f1 100644 --- a/crates/ui-egui/src/panels/detail/geometry.rs +++ b/crates/ui-egui/src/panels/detail/geometry.rs @@ -1,6 +1,6 @@ #[cfg(test)] use super::info::label_badge_rect; -use super::*; +use super::{Affine, Frame, LightcraftApp, PhotoId, Point, Pos2, Rect, Zoom, json, pos2, vec2}; /// Screen ↔ normalized-image mapping for the displayed frame. #[derive(Clone, Copy)] @@ -296,7 +296,7 @@ mod preview_geometry_tests { fn before_label_badge_keeps_clear_of_the_filter_pill_and_the_right_edge() { let text = vec2(40.0, 14.0); let pane = Rect::from_min_size(pos2(100.0, 50.0), vec2(800.0, 600.0)); - let inset = super::FILTER_PILL_INSET + super::FILTER_PILL_HEIGHT; + let inset = crate::panels::detail::FILTER_PILL_INSET + crate::panels::detail::FILTER_PILL_HEIGHT; let past = label_badge_rect(Rect::from_min_size(pos2(-900.0, -700.0), vec2(3000.0, 2000.0)), pane, text, inset); assert_eq!(past.min, pos2(108.0, 50.0 + inset + 8.0)); let under = label_badge_rect(Rect::from_min_size(pos2(200.0, 60.0), vec2(400.0, 300.0)), pane, text, inset); diff --git a/crates/ui-egui/src/panels/detail/info.rs b/crates/ui-egui/src/panels/detail/info.rs index 520ad6213..da5ae1bed 100644 --- a/crates/ui-egui/src/panels/detail/info.rs +++ b/crates/ui-egui/src/panels/detail/info.rs @@ -1,5 +1,5 @@ use super::geometry::focus_box; -use super::*; +use super::{Color32, LightcraftApp, PhotoId, Pos2, Rect, Sense, Stroke, Tokens, json, pos2, register, register_geometry, vec2}; /// Draw the camera's focus box over a photo drawn in `img` (View ▸ Focus Point): green corner /// brackets with a dark keyline, the way cameras show their AF frame, never smaller than 14 pt. diff --git a/crates/ui-egui/src/panels/detail/masks.rs b/crates/ui-egui/src/panels/detail/masks.rs index 073de8aa2..50424f6b3 100644 --- a/crates/ui-egui/src/panels/detail/masks.rs +++ b/crates/ui-egui/src/panels/detail/masks.rs @@ -1,5 +1,8 @@ use super::geometry::CanvasMap; -use super::*; +use super::{ + Color32, DevelopSettings, Gesture, LightcraftApp, MaskShape, Point, Pos2, Rect, Sense, Stroke, StrokeKind, Tokens, ToolOverlay, json, pos2, + register, vec2, +}; /// What the user did to a face box this frame. pub(super) enum RegionEdit { diff --git a/crates/ui-egui/src/panels/detail/retouch.rs b/crates/ui-egui/src/panels/detail/retouch.rs index 64f4c2281..d74ead9ba 100644 --- a/crates/ui-egui/src/panels/detail/retouch.rs +++ b/crates/ui-egui/src/panels/detail/retouch.rs @@ -1,6 +1,6 @@ use super::geometry::CanvasMap; use super::masks::{pin, pins_live}; -use super::*; +use super::{Color32, DevelopSettings, Gesture, LightcraftApp, Point, Pos2, Rect, Stroke, ToolOverlay, json, register, vec2}; // ------------------------------------------------------------------------ remove diff --git a/crates/ui-egui/src/panels/detail/window.rs b/crates/ui-egui/src/panels/detail/window.rs index 0b441913b..f4ec89c5b 100644 --- a/crates/ui-egui/src/panels/detail/window.rs +++ b/crates/ui-egui/src/panels/detail/window.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, LightcraftApp, PhotoId, Rect, Slot, pos2, vec2}; /// Whether an overlay can be rendered on a region tile without changing what the tile means. Mask (one or all) /// overlays are evaluated from the same mask alpha as the full render; Point Color range and Visualize diff --git a/crates/ui-egui/src/panels/edit/curve.rs b/crates/ui-egui/src/panels/edit/curve.rs index 856211e66..0eec3a5e4 100644 --- a/crates/ui-egui/src/panels/edit/curve.rs +++ b/crates/ui-egui/src/panels/edit/curve.rs @@ -1,6 +1,9 @@ use super::histogram::preview_histogram; use super::sliders::control; -use super::*; +use super::{ + Align2, Color32, DevelopSettings, Icon, LightcraftApp, Mesh, PARAM_AMOUNTS, ParametricCurve, PhotoId, Point, Pos2, Rect, RichText, Sense, Stroke, + Tokens, Vertex, controls, hex, json, paint, pos2, register, text_button, vec2, +}; // ------------------------------------------------------------------------------ tone curve diff --git a/crates/ui-egui/src/panels/edit/grading.rs b/crates/ui-egui/src/panels/edit/grading.rs index a0b430acd..61f154300 100644 --- a/crates/ui-egui/src/panels/edit/grading.rs +++ b/crates/ui-egui/src/panels/edit/grading.rs @@ -1,5 +1,8 @@ use super::sliders::control; -use super::*; +use super::{ + Align2, Color32, DevelopSettings, Icon, LightcraftApp, Mesh, PhotoId, Pos2, Rect, Sense, Stroke, Tokens, Vertex, json, paint, pos2, register, + vec2, +}; // ------------------------------------------------------------------------------ colour grading diff --git a/crates/ui-egui/src/panels/edit/histogram.rs b/crates/ui-egui/src/panels/edit/histogram.rs index 44d791c4a..085fbda5d 100644 --- a/crates/ui-egui/src/panels/edit/histogram.rs +++ b/crates/ui-egui/src/panels/edit/histogram.rs @@ -1,6 +1,9 @@ #[cfg(test)] use super::sliders::reset_tip; -use super::*; +use super::{ + Align2, Color32, Histogram, Icon, LightcraftApp, Mesh, PhotoId, Pos2, Rect, RichText, RightPanel, Sense, Slot, Stroke, Tokens, Vertex, divider, + hex, json, paint, pos2, register, vec2, +}; // ------------------------------------------------------------------------------ histogram diff --git a/crates/ui-egui/src/panels/edit/mixer.rs b/crates/ui-egui/src/panels/edit/mixer.rs index b869b32dd..0bddba375 100644 --- a/crates/ui-egui/src/panels/edit/mixer.rs +++ b/crates/ui-egui/src/panels/edit/mixer.rs @@ -1,6 +1,6 @@ use super::curve::set_tat; use super::sliders::{control, sub_title}; -use super::*; +use super::{BAND_COLORS, Color32, DevelopSettings, Icon, LightcraftApp, Sense, Stroke, Tokens, hex, json, paint, register, text_button, vec2}; // ------------------------------------------------------------------------------ colour mixer diff --git a/crates/ui-egui/src/panels/edit/sliders.rs b/crates/ui-egui/src/panels/edit/sliders.rs index aad9cdf6a..00adb9928 100644 --- a/crates/ui-egui/src/panels/edit/sliders.rs +++ b/crates/ui-egui/src/panels/edit/sliders.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Align2, ControlSpec, DevelopSettings, Icon, LightcraftApp, PhotoId, Section, Sense, SliderOut, Tokens, Track, Value, controls, divider, json, + pos2, register, section_header, slider, vec2, +}; /// Commit a slider interaction: begin → live updates → end, so a drag is one undo step. pub fn apply_slider_out( diff --git a/crates/ui-egui/src/panels/left/albums.rs b/crates/ui-egui/src/panels/left/albums.rs index e0204c826..bfcc6a219 100644 --- a/crates/ui-egui/src/panels/left/albums.rs +++ b/crates/ui-egui/src/panels/left/albums.rs @@ -1,6 +1,6 @@ use super::folders::{folder_menu, is_within}; use super::rows::{INDENT_STEP, indent_x, row_sensed, set_tree_open, tree_open, tree_toggle}; -use super::*; +use super::{Album, AlbumId, Align2, Icon, LibrarySource, LightcraftApp, Rect, Sense, Tokens, json, pos2, register, vec2}; /// Albums: the tree of albums and the folders they are sorted into. `reveal`: an album just made, /// whose folders open down to it. diff --git a/crates/ui-egui/src/panels/left/dates.rs b/crates/ui-egui/src/panels/left/dates.rs index 49d8cf898..9b822c6e5 100644 --- a/crates/ui-egui/src/panels/left/dates.rs +++ b/crates/ui-egui/src/panels/left/dates.rs @@ -1,5 +1,5 @@ use super::rows::{INDENT_STEP, row, tree_open, tree_toggle}; -use super::*; +use super::{Icon, LibrarySource, LightcraftApp, json}; /// By Date: year → month → day; a click filters by that prefix, the triangle opens a level. pub(super) fn date_rows(app: &mut LightcraftApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/panels/left/folders.rs b/crates/ui-egui/src/panels/left/folders.rs index 95915b740..1ded12f07 100644 --- a/crates/ui-egui/src/panels/left/folders.rs +++ b/crates/ui-egui/src/panels/left/folders.rs @@ -1,7 +1,7 @@ use super::albums::pointer_over_row; use super::local::{same_folder, subfolders}; use super::rows::{INDENT_STEP, LIBFOLDER_REVEALED, out_of_sight, row, row_sensed, set_tree_open, tree_open, tree_toggle}; -use super::*; +use super::{Album, FolderNode, Icon, LibrarySource, LightcraftApp, Sense, Tokens, json, register, vec2}; /// A folder on disk with a disclosure triangle: click browses it, the triangle lists its /// subfolders. In the tree that holds the folder being browsed (`reveal`), the folders on the diff --git a/crates/ui-egui/src/panels/left/keywords.rs b/crates/ui-egui/src/panels/left/keywords.rs index 81997f2e2..4342efcc6 100644 --- a/crates/ui-egui/src/panels/left/keywords.rs +++ b/crates/ui-egui/src/panels/left/keywords.rs @@ -1,6 +1,6 @@ use super::dates::browse_all_photos; use super::rows::{INDENT_STEP, out_of_sight, row, tree_open, tree_toggle}; -use super::*; +use super::{Icon, KeywordNode, LightcraftApp, json}; /// "Keywords": the library's keyword tree with photo counts (`a|b|c` keywords nest). A click /// filters the grid by the keyword (children included), the triangle opens a level, and the diff --git a/crates/ui-egui/src/panels/left/local.rs b/crates/ui-egui/src/panels/left/local.rs index e1a62d8ea..6d3549570 100644 --- a/crates/ui-egui/src/panels/left/local.rs +++ b/crates/ui-egui/src/panels/left/local.rs @@ -1,6 +1,6 @@ use super::folders::folder_tree; use super::rows::row; -use super::*; +use super::{Icon, LibrarySource, LightcraftApp, SidebarSection, json, register}; /// Local's folders as they stand this frame, and the folder being browsed among them. pub(super) struct LocalNow { diff --git a/crates/ui-egui/src/panels/left/rows.rs b/crates/ui-egui/src/panels/left/rows.rs index 541d98b06..edfdff167 100644 --- a/crates/ui-egui/src/panels/left/rows.rs +++ b/crates/ui-egui/src/panels/left/rows.rs @@ -1,5 +1,5 @@ use super::albums::MARK_W; -use super::*; +use super::{Align2, Icon, LightcraftApp, Rect, Sense, Tokens, paint, pos2, register, vec2}; /// How wide the sidebar's content needs to be, from the widest row of the last frame: rows are /// drawn at this width (or the panel's, if wider), and the sidebar scrolls sideways when it diff --git a/crates/ui-egui/src/panels/left/sections.rs b/crates/ui-egui/src/panels/left/sections.rs index 42cc631b5..5597904a8 100644 --- a/crates/ui-egui/src/panels/left/sections.rs +++ b/crates/ui-egui/src/panels/left/sections.rs @@ -1,6 +1,6 @@ use super::albums::top_level_drop_target; use super::rows::{ROW_HEIGHT, note_width}; -use super::*; +use super::{Align2, Icon, LightcraftApp, Rect, Sense, SidebarSection, Tokens, icon_button, json, paint, pos2, register, vec2}; /// Space above a section's header. pub(super) const SECTION_GAP: f32 = 10.0; diff --git a/crates/ui-egui/src/panels/right/crop.rs b/crates/ui-egui/src/panels/right/crop.rs index 67efd91f6..0b71fffe8 100644 --- a/crates/ui-egui/src/panels/right/crop.rs +++ b/crates/ui-egui/src/panels/right/crop.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LightcraftApp, PhotoId, Tokens, divider, header, json, padded, register, slider, text_button}; /// The aspect presets of the Crop panel: (menu label, `crop.aspect` value). const ASPECT_PRESETS: [(&str, &str); 10] = [ diff --git a/crates/ui-egui/src/panels/right/history.rs b/crates/ui-egui/src/panels/right/history.rs index 49229783b..d434075ad 100644 --- a/crates/ui-egui/src/panels/right/history.rs +++ b/crates/ui-egui/src/panels/right/history.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align2, Icon, LightcraftApp, PhotoId, Rect, Sense, Tokens, header, json, padded, paint, pos2, register, text_button, vec2}; pub(super) fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let Some(p) = app.session.catalog.photo(id).cloned() else { return }; diff --git a/crates/ui-egui/src/panels/right/info.rs b/crates/ui-egui/src/panels/right/info.rs index 4d7c198a0..5810a0f27 100644 --- a/crates/ui-egui/src/panels/right/info.rs +++ b/crates/ui-egui/src/panels/right/info.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Icon, LightcraftApp, PhotoId, Sense, Tokens, divider, header, json, padded, register, text_button, vec2}; pub(super) fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let Some(p) = app.session.catalog.photo(id).cloned() else { return }; diff --git a/crates/ui-egui/src/panels/right/keywords.rs b/crates/ui-egui/src/panels/right/keywords.rs index 6d7d5868b..b6598476a 100644 --- a/crates/ui-egui/src/panels/right/keywords.rs +++ b/crates/ui-egui/src/panels/right/keywords.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{HOLD, LightcraftApp, PhotoId, Rect, Tokens, fold_header, json, padded, register, text_button, vec2}; pub(super) fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // (the box is the selection's: the active photo only says that there is one) diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 8eb13aa85..cc0802248 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Session, Value, pick}; pub type PickFiles = Box Vec>; diff --git a/crates/ui-egui/src/state/dialog.rs b/crates/ui-egui/src/state/dialog.rs index edc4946cd..7f9226b6e 100644 --- a/crates/ui-egui/src/state/dialog.rs +++ b/crates/ui-egui/src/state/dialog.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; /// Settings group ids (`SettingsGroup` serde names) a new preset includes by default: everything /// but crop, masks, spots and red eye. diff --git a/crates/ui-egui/src/state/settings.rs b/crates/ui-egui/src/state/settings.rs index 2d748f5a7..fff943b4c 100644 --- a/crates/ui-egui/src/state/settings.rs +++ b/crates/ui-egui/src/state/settings.rs @@ -1,7 +1,7 @@ use super::view::LoupeInfo; #[cfg(test)] use super::view::{InfoOverlay, LoupeInfoField}; -use super::*; +use super::{Deserialize, Serialize}; /// Which view the app opens in. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/ui-egui/src/state/view.rs b/crates/ui-egui/src/state/view.rs index 1bbfd4349..611002647 100644 --- a/crates/ui-egui/src/state/view.rs +++ b/crates/ui-egui/src/state/view.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] From 71bf82f7bba0aa6399e8cde4b924ce5163ecbd30 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 20:08:31 +0000 Subject: [PATCH 25/39] refactor(ui-egui): split run_ui_command into parts and per-command functions --- crates/ui-egui/src/menus/run.rs | 1512 +++++++++++++++++-------------- 1 file changed, 827 insertions(+), 685 deletions(-) diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 9977affac..52e6006eb 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -168,6 +168,25 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Option>; 12] = [ + ui_commands_1, + ui_commands_2, + ui_commands_3, + ui_commands_4, + ui_commands_5, + ui_commands_6, + ui_commands_7, + ui_commands_8, + ui_commands_9, + ui_commands_10, + ui_commands_11, + ui_commands_12, + ]; + PARTS.iter().find_map(|part| part(app, &ctx, id, p)) +} + +/// Part 1 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_1(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { let r: Result = match id { "view.photoGrid" => { app.ui.view = ViewMode::PhotoGrid; @@ -203,7 +222,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Option return None, + }; + Some(r) +} + +/// Part 2 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_2(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "view.detail" => { app.ui.view = ViewMode::Detail; Ok(Value::Null) @@ -369,47 +396,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::panels::compare::make_select(app), "view.autoAdvance" => { app.ui.auto_advance = !app.ui.auto_advance; - app.toast(&ctx, crate::i18n::tr(if app.ui.auto_advance { "Auto Advance On" } else { "Auto Advance Off" })); + app.toast(ctx, crate::i18n::tr(if app.ui.auto_advance { "Auto Advance On" } else { "Auto Advance Off" })); Ok(json!({"autoAdvance": app.ui.auto_advance})) } - "view.back" => { - if app.ui.dialog.is_some() { - app.ui.dialog = None; - } else if app.ui.lights_out != crate::state::LightsOut::On { - app.ui.lights_out = crate::state::LightsOut::On; - } else if app.ui.keyword_painter.is_some() { - app.ui.keyword_painter = None; - } else if app.ui.fullscreen { - // Through `slideshow::end`: it also releases the held slide textures. Clearing the - // two fields directly left every rendered slide in the renderer. - crate::panels::slideshow::end(app); - } else if !app.ui.tool.is_empty() { - app.ui.tool.clear(); - } else if app.ui.right == RightPanel::Crop && app.ui.view == ViewMode::Detail { - // Esc leaves the crop tool (the crop stays), as C does; the next Esc goes back - panel(app, &ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); - } else if app.ui.view == ViewMode::People && app.ui.person_page.is_some() { - app.ui.person_page = None; - } else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey | ViewMode::Reference) { - app.ui.view = ViewMode::Detail; - } else if app.ui.view == ViewMode::Detail { - // a photo opened from a person's page (or the unnamed faces) goes back there, any other to the grid - match app.ui.person_from.take() { - Some((name, photo)) if app.session.active().is_some_and(|a| a.0 == photo) => { - app.ui.view = ViewMode::People; - app.ui.person_page = name; - } - _ => app.ui.view = ViewMode::PhotoGrid, - } - } - Ok(Value::Null) - } + "view.back" => view_back_command(app, ctx), "view.slideshow" => { // the photos in view, full screen, with the playback settings (see panels::slideshow) let now = app.tick_now(); let r = crate::panels::slideshow::start(app, p, now); if r.is_ok() { - app.toast(&ctx, crate::i18n::tr("Slideshow · Space pauses · Esc ends")); + app.toast(ctx, crate::i18n::tr("Slideshow · Space pauses · Esc ends")); } r } @@ -417,6 +413,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option return None, + }; + Some(r) +} + +/// Part 3 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_3(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "view.slideshowOptions" => { app.ui.dialog = Some(Dialog::Settings { tab: "slideshow".into() }); Ok(Value::Null) @@ -464,51 +468,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option "Info Overlay: File", crate::state::InfoOverlay::Exposure => "Info Overlay: Exposure", }; - app.toast(&ctx, crate::i18n::tr(label)); + app.toast(ctx, crate::i18n::tr(label)); Ok(json!({"infoOverlay": app.ui.info_overlay})) } - "view.viewOptions" => { - // `info1` / `info2` set the lines, `briefly` the Info that Show Briefly When Photo Changes - // shows and `loupeMessages` Show message when loading or rendering photos, for an agent; - // without any of them the window opens - let mut info = app.ui.settings.loupe_info; - let mut set = false; - // `loupeMessages` sets Show message when loading or rendering photos - let messages = match p.get("loupeMessages") { - None => app.ui.settings.loupe_messages, - Some(v) => match v.as_bool() { - Some(on) => { - set = true; - on - } - None => return Some(Err("`loupeMessages`: true|false".into())), - }, - }; - for (key, lines) in [("info1", &mut info.info1), ("info2", &mut info.info2)] { - let Some(v) = p.get(key) else { continue }; - match serde_json::from_value(v.clone()) { - Ok(v) => *lines = v, - Err(_) => { - return Some(Err(format!("`{key}`: three of none|fileName|captureDate|dimensions|exposure|camera|lens|cameraAndLens"))); - } - } - set = true; - } - if let Some(v) = p.get("briefly") { - match serde_json::from_value(v.clone()) { - Ok(v) => info.briefly = v, - Err(_) => return Some(Err("`briefly`: off|basic|exposure (neither, Info 1 or Info 2)".into())), - } - set = true; - } - if set { - app.ui.settings.loupe_info = info; - app.ui.settings.loupe_messages = messages; - } else { - app.ui.dialog = Some(Dialog::ViewOptions); - } - Ok(json!({"loupeInfo": app.ui.settings.loupe_info, "loupeMessages": app.ui.settings.loupe_messages})) - } + "view.viewOptions" => view_view_options_command(app, p), "view.chooseLayoutImage" => { let path = match p.get("path").and_then(Value::as_str) { Some(path) => path.trim().to_string(), @@ -531,7 +494,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { @@ -541,7 +504,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { @@ -561,6 +524,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option return None, + }; + Some(r) +} + +/// Part 4 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_4(app: &mut LightcraftApp, _ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "app.settings" => { let tab = p.get("tab").and_then(Value::as_str).unwrap_or("general"); if !crate::panels::settings::TABS.iter().any(|(id, _)| *id == tab) { @@ -665,6 +636,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option return None, + }; + Some(r) +} + +/// Part 5 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_5(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "view.clickZoom" => { // {ratio?: 1|2|3|4|8} → {ratio} if let Some(r) = p.get("ratio").and_then(Value::as_f64) { @@ -733,67 +712,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - // {on?, space?, profile?: ICC path | null, intent?: perceptual|relative, paperInk?, - // destWarning?, displayWarning?}; no params toggles - let has = |k: &str| p.get(k).is_some(); - if let Some(s) = p.get("space").and_then(Value::as_str) { - match lightcraft_engine::pipeline::OutputSpace::parse(s) { - Some(sp) => { - app.ui.proof.space = sp; - // a standard space replaces a device profile (unless one is given too) - app.ui.proof_profile = None; - } - None => return Some(Err(format!("view.softProof: unknown space {s:?} (srgb|displayP3|adobeRgb|proPhoto|rec2020)"))), - } - } - if let Some(i) = p.get("intent") { - match i.as_str().and_then(lightcraft_engine::proof::ProofIntent::parse) { - Some(i) => app.ui.proof_intent = i, - None => return Some(Err("view.softProof: intent must be perceptual or relative".into())), - } - } - if let Some(b) = p.get("paperInk").and_then(Value::as_bool) { - app.ui.proof_paper_ink = b; - } - let path = match p.get("profile") { - Some(Value::String(s)) => Some(s.clone()), - Some(Value::Null) => { - app.ui.proof_profile = None; - None - } - Some(_) => return Some(Err("view.softProof: profile must be an ICC file path or null".into())), - None => app.ui.proof_profile.as_ref().map(|(f, _)| f.clone()), - }; - match path { - Some(path) => match lightcraft_engine::proof::load(&path, app.ui.proof_intent, app.ui.proof_paper_ink) { - Ok(pp) => { - app.ui.proof.profile = pp.id; - app.ui.proof_profile = Some((path, pp.name)); - } - Err(e) => return Some(Err(format!("view.softProof: {e}"))), - }, - None => app.ui.proof.profile = 0, - } - if let Some(b) = p.get("destWarning").and_then(Value::as_bool) { - app.ui.proof.dest_warning = b; - } - if let Some(b) = p.get("displayWarning").and_then(Value::as_bool) { - app.ui.proof.display_warning = b; - } - app.ui.soft_proof = match p.get("on").and_then(Value::as_bool) { - Some(b) => b, - None if ["space", "destWarning", "displayWarning", "profile", "intent", "paperInk"].iter().any(|k| has(k)) => app.ui.soft_proof, - None => !app.ui.soft_proof, - }; - if app.ui.soft_proof && !matches!(app.ui.view, ViewMode::Detail | ViewMode::Reference) { - app.ui.view = ViewMode::Detail; - } - let pr = app.ui.proof; - let profile = app.ui.proof_profile.as_ref().map(|(f, n)| json!({"path": f, "name": n})); - Ok(json!({"on": app.ui.soft_proof, "space": pr.space, "profile": profile, "intent": app.ui.proof_intent.name(), - "paperInk": app.ui.proof_paper_ink, "destWarning": pr.dest_warning, "displayWarning": pr.display_warning})) - } + "view.softProof" => view_soft_proof_command(app, id, p), "view.histogram" => { app.ui.histogram = !app.ui.histogram; Ok(Value::Null) @@ -821,9 +740,17 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option return None, + }; + Some(r) +} + +/// Part 6 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_6(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "view.maskOverlayColor" => { // no params: the next of the panel's swatch colours if p.get("color").is_none() && p.get("opacity").is_none() { @@ -876,7 +803,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { @@ -932,6 +859,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Err("local.hide needs a path".into()), }, + _ => return None, + }; + Some(r) +} + +/// Part 7 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_7(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "local.restoreHidden" => { // one path, or (no path) every hidden location match p.get("path").and_then(Value::as_str) { @@ -952,7 +887,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - panel(app, &ctx, RightPanel::Edit, "Edit"); + panel(app, ctx, RightPanel::Edit, "Edit"); Ok(Value::Null) } "panel.profiles" => { @@ -968,47 +903,47 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - panel(app, &ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); + panel(app, ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); Ok(Value::Null) } "panel.remove" => { - panel(app, &ctx, RightPanel::Remove, "Remove"); + panel(app, ctx, RightPanel::Remove, "Remove"); if app.ui.right == RightPanel::Remove && app.ui.tool.is_empty() { app.ui.tool = "remove".into(); } Ok(Value::Null) } "panel.masking" => { - panel(app, &ctx, RightPanel::Masking, "Masking"); + panel(app, ctx, RightPanel::Masking, "Masking"); Ok(Value::Null) } "panel.redeye" => { - panel(app, &ctx, RightPanel::RedEye, "Red Eye"); + panel(app, ctx, RightPanel::RedEye, "Red Eye"); Ok(Value::Null) } "panel.info" => { - panel(app, &ctx, RightPanel::Info, "Info"); + panel(app, ctx, RightPanel::Info, "Info"); Ok(Value::Null) } "panel.keywords" => { - panel(app, &ctx, RightPanel::Keywords, "Keywords"); + panel(app, ctx, RightPanel::Keywords, "Keywords"); Ok(Value::Null) } "panel.addKeywords" => { // opens Keywording when it is folded shut; the panel stays open when pressed again if app.ui.right != RightPanel::Keywords { - panel(app, &ctx, RightPanel::Keywords, "Keywords"); + panel(app, ctx, RightPanel::Keywords, "Keywords"); } app.ui.folded_sections.retain(|s| s != "keywording"); app.ui.focus_keywords = Some(ctx.cumulative_pass_nr()); Ok(Value::Null) } "panel.versions" => { - panel(app, &ctx, RightPanel::Versions, "Versions"); + panel(app, ctx, RightPanel::Versions, "Versions"); Ok(Value::Null) } "panel.activity" => { - panel(app, &ctx, RightPanel::Activity, "History"); + panel(app, ctx, RightPanel::Activity, "History"); Ok(Value::Null) } "panel.presets" => { @@ -1033,6 +968,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option return None, + }; + Some(r) +} + +/// Part 8 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_8(app: &mut LightcraftApp, _ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { s if s.starts_with("section.") => bool_param(s, p, "solo").and_then(|solo| { // only the Edit sections listed above: an unknown one would close the real ones if !UI_COMMANDS.iter().any(|c| c.0 == s) { @@ -1133,41 +1076,19 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - // {id?}: edit that smart album's rules; without: a new one starting at Rating ≥ 3 - let album = p - .get("id") - .and_then(Value::as_u64) - .and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id)).filter(|a| a.is_smart()).cloned()); - app.ui.dialog = Some(match album { - Some(a) => Dialog::SmartRules { - id: Some(a.id.0), - name: a.name.clone(), - // older rules read as they always matched (RuleSet::upgrade) - rules: a - .smart - .as_ref() - .and_then(|f| f.rule_set.clone()) - .map(|mut r| { - r.upgrade(); - r - }) - .unwrap_or_default(), - parent: None, - }, - None => Dialog::SmartRules { - id: None, - name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), - rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, - parent: parent_param(p), - }, - }); - Ok(Value::Null) - } + "dialog.smartAlbum" => dialog_smart_album_command(app, id, p), "dialog.newSmartAlbum" => { app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), parent: parent_param(p) }); Ok(Value::Null) } + _ => return None, + }; + Some(r) +} + +/// Part 9 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_9(app: &mut LightcraftApp, _ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "dialog.captureTime" => { // a time that doesn't fully read isn't offered as the starting point (`10:0` would read as midnight) let time = app @@ -1268,6 +1189,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option return None, + }; + Some(r) +} + +/// Part 10 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_10(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "dialog.export" => { let prev = app.session.last_export.clone().unwrap_or_default(); let u = |k: &str, d: u64| prev.get(k).and_then(Value::as_u64).unwrap_or(d); @@ -1303,9 +1232,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::updates_ui::check(app), - "photo.editInPhotoCraft" => edit_in(app, &ctx, p, true), - "photo.editInExternal" => edit_in(app, &ctx, p, false), - "photo.openOriginalInExternal" => open_original_in(app, &ctx, p), + "photo.editInPhotoCraft" => edit_in(app, ctx, p, true), + "photo.editInExternal" => edit_in(app, ctx, p, false), + "photo.openOriginalInExternal" => open_original_in(app, ctx, p), "dialog.cull" => { let (burst_gap, similarity) = app.ui.cull_burst.unwrap_or((10.0, 0.93)); app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true, burst_gap, similarity }); @@ -1315,51 +1244,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - let info = app.session.execute("library.info", &json!({})).unwrap_or_default(); - let gpu = (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten(); - let mb = |b: u64| format!("{:.0} MB", b as f64 / (1u64 << 20) as f64); - let mut rows = vec![ - ("Version".to_string(), env!("CARGO_PKG_VERSION").to_string()), - ("System".to_string(), format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH)), - ("CPU threads".to_string(), std::thread::available_parallelism().map(|n| n.get().to_string()).unwrap_or_else(|_| "?".into())), - ( - "GPU".to_string(), - gpu.unwrap_or_else(|| match lightcraft_engine::gpu::unavailable_reason() { - Some(why) => crate::i18n::tr_format!("none (CPU rendering): {why}", why = why), - None => "none (CPU rendering)".into(), - }), - ), - ("GPU rendering".to_string(), if app.ui.settings.gpu { "on".into() } else { "off".into() }), - ("Memory budget".to_string(), mb(lightcraft_engine::memory::default_budget() as u64)), - ( - "Preview size".to_string(), - match app.ui.settings.preview_limit { - 0 => "automatic".to_string(), - n => format!("{n} px"), - }, - ), - ("Photos".to_string(), info["photos"].to_string()), - ("Albums".to_string(), info["albums"].to_string()), - ]; - if let Some(dir) = info["dir"].as_str().or(info["path"].as_str()) { - rows.push(("Library".into(), dir.to_string())); - } - rows.push(("Frame time".into(), format!("{:.1} ms ({:.0} fps)", app.perf.frame_ms, app.perf.fps))); - rows.push(( - "Frame update".into(), - format!("{:.1} ms (logic {:.1} ms; slowest {:.0} ms)", app.perf.update_ms, app.perf.logic_ms, app.perf.max_update_ms), - )); - rows.push(("Last loupe render".into(), format!("{:.0} ms", app.renderer.last_main_ms))); - if let Some(f) = lightcraft_engine::gpu::last_fallback() { - rows.push(("Last GPU fallback".into(), f)); - } - let r = json!(rows.iter().map(|(k, v)| json!({"label": k, "value": v})).collect::>()); - if p.get("open").and_then(Value::as_bool).unwrap_or(true) { - app.ui.dialog = Some(Dialog::SystemInfo { rows }); - } - Ok(r) - } + "app.systemInfo" => app_system_info_command(app, p), "app.shortcuts" => { app.ui.dialog = Some(Dialog::Shortcuts); Ok(Value::Null) @@ -1420,6 +1305,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Ok(Value::Null), } } + _ => return None, + }; + Some(r) +} + +/// Part 11 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_11(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "app.quit" => { app.ui.quit = true; Ok(Value::Null) @@ -1429,162 +1322,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - let within = match p.get("within") { - None | Some(Value::Null) => None, - Some(Value::String(path)) if !path.is_empty() => Some(path.clone()), - Some(Value::String(_)) => return Some(Err("file.findMissing: `within` must not be empty".into())), - Some(value) => return Some(Err(format!("file.findMissing: `within` must be a folder path, not {value}"))), - }; - let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false); - let timeout = match p.get("timeout") { - None | Some(Value::Null) => std::time::Duration::from_secs(600), - Some(Value::Number(n)) => { - if let Some(s) = n.as_f64() { - if s.is_finite() && s >= 0.0 { - match std::time::Duration::try_from_secs_f64(s) { - Ok(d) => d, - Err(_) => return Some(Err("file.findMissing: `timeout` is too large".into())), - } - } else { - return Some(Err("file.findMissing: `timeout` must be a non-negative number of seconds".into())); - } - } else { - return Some(Err("file.findMissing: `timeout` must be a number of seconds".into())); - } - } - Some(other) => return Some(Err(format!("file.findMissing: `timeout` must be a number of seconds, not {other}"))), - }; - let title = if within.is_some() { "Locate Missing Files…" } else { "Find Missing Photos…" }; - let folder = match p.get("folder").and_then(Value::as_str) { - Some(f) => Some(f.to_string()), - None => match crate::pick::ask(app, id, p, "folder", PickRequest::folder(crate::i18n::tr(title)), |s| { - s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x]) - }) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later | Picked::Unavailable => None, - }, - }; - let Some(folder) = folder else { return Some(Ok(Value::Null)) }; - // the search (checking every photo's file, walking the folder) runs on a worker thread; - // the relinking happens back here, as one undo step - const LABEL: &str = "Find Missing Photos"; - if app.tasks.is_running(LABEL) { - return Some(Err("Find Missing Photos is already searching".into())); - } - let mut candidates = lightcraft_engine::cmd::missing::find_candidates(&app.session.catalog); - if let Some(within) = within { - candidates.retain(|candidate| lightcraft_catalog::query::folder_within(&candidate.path, &within)); - } - #[cfg(test)] - let test_delay = app.ui.find_missing_test_delay; - let work = move || { - #[cfg(test)] - if let Some(d) = test_delay { - std::thread::sleep(d); - } - lightcraft_engine::cmd::missing::plan_find_missing(&candidates, &folder) - }; - let done = |app: &mut LightcraftApp, ctx: &egui::Context, plan: Result| { - // relinked under the session as it is now: photos relinked meanwhile and files - // now in use are skipped - let r = plan.and_then(|plan| app.run("library.findMissing", plan.to_json())); - match r { - Ok(v) => { - let n = v["found"].as_array().map_or(0, Vec::len); - let left = v["missing"].as_u64().unwrap_or(0); - let unsure = v["ambiguous"].as_array().map_or(0, Vec::len); - let unsure = if unsure > 0 { - crate::i18n::tr_format!(" ({unsure} with several look-alike files: use Locate)", unsure = unsure) - } else { - String::new() - }; - app.toast( - ctx, - crate::i18n::tr_format!( - "Found {n} missing photo{}; {left} still missing{unsure}", - if n == 1 { "" } else { "s" }, - left = left, - n = n, - unsure = unsure - ), - ); - app.ui.last_find_missing = Some(v.clone()); - app.ui.last_find_missing_result = Some(Ok(v)); - } - Err(e) => { - app.toast(ctx, e.clone()); - app.ui.last_find_missing_result = Some(Err(e)); - } - } - }; - app.ui.last_find_missing = None; - app.ui.last_find_missing_result = None; - if let Err(e) = crate::tasks::spawn(app, LABEL, Some("findMissing"), work, done) { - return Some(Err(e)); - } - if wait { - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - let completed = crate::tasks::wait_for(app, &ctx, LABEL, timeout); - if !completed { - return Some(Err("Find Missing Photos: search timed out (search continues in the background)".into())); - } - return match app.ui.last_find_missing_result.take() { - Some(Ok(v)) => Some(Ok(v)), - Some(Err(e)) => Some(Err(e)), - None => Some(Err("Find Missing Photos: worker terminated unexpectedly".into())), - }; - } - Ok(json!({"background": true})) - } - "photo.tagFromTracklog" => { - // a GPX file → GPS for the selected photos by capture time (one undo step) - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => { - let req = PickRequest::file(crate::i18n::tr("Auto-Tag from Tracklog"), crate::i18n::tr("GPS Track Log"), &["gpx"]); - match crate::pick::ask(app, id, p, "path", req, |s| s.pick_tracklog.as_mut().map(|f| f())) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later | Picked::Unavailable => None, - } - } - }; - let Some(path) = path else { return Some(Ok(Value::Null)) }; - let mut params = p.as_object().cloned().unwrap_or_default(); - params.insert("path".into(), json!(path)); - let ask_zone = !params.contains_key("offset"); - let params = Value::Object(params); - if ask_zone { - // GPX times are UTC, camera clocks are local: ask for the camera's zone, then come back here - crate::panels::dialogs::prompt( - app, - "Auto-Tag from Tracklog", - "Camera time zone, e.g. -07:00 (empty: UTC)", - "", - "photo.tagFromTracklog", - params, - "offset", - ); - return Some(Ok(Value::Null)); - } - let r = app.run("photo.autoTagTracklog", params); - if let Ok(v) = &r { - let n = v["tagged"].as_u64().unwrap_or(0); - let sk = &v["skipped"]; - let mut msg = crate::i18n::tr_format!("Tagged {n} photo{} from the tracklog", if n == 1 { "" } else { "s" }, n = n); - let outside = sk["outside"].as_u64().unwrap_or(0); - if outside > 0 { - msg += &crate::i18n::tr_format!("; {outside} outside its time range", outside = outside); - } - let kept = sk["hasGps"].as_u64().unwrap_or(0); - if kept > 0 { - msg += &crate::i18n::tr_format!("; {kept} already had a location", kept = kept); - } - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - app.toast(&ctx, msg); - } - r - } + "file.findMissing" => file_find_missing_command(app, id, p), + "photo.tagFromTracklog" => photo_tag_from_tracklog_command(app, ctx, id, p), "photo.locate" => { let Some(id) = app.session.active() else { return Some(Err("no photo selected".into())) }; let path = match p.get("path").and_then(Value::as_str) { @@ -1640,208 +1379,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Ok(Value::Null), } } - "file.importPresets" => { - let paths = match p.get("paths").and_then(Value::as_array) { - Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), - None => { - let req = PickRequest::files( - Some(crate::i18n::tr("Import Presets").into()), - crate::i18n::tr("Presets & Profiles"), - crate::pick::PRESET_EXTENSIONS, - ); - match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_preset_files.as_mut().map(|f| f())) { - Picked::Now(paths) => paths, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - if paths.is_empty() { - return Some(Ok(Value::Null)); - } - // .cube LUTs (and those inside zips / folders) become profiles - let lut_paths: Vec<&String> = paths - .iter() - .filter(|p| { - let l = p.to_ascii_lowercase(); - l.ends_with(".cube") || l.ends_with(".zip") || std::path::Path::new(p.as_str()).is_dir() - }) - .collect(); - // one toast for both halves (a second toast would replace the first) - let mut parts: Vec = Vec::new(); - // `name: reason` for each file that couldn't be imported - let mut problems: Vec = Vec::new(); - let mut profiles_result = Value::Null; - if !lut_paths.is_empty() { - match app.session.execute("profile.import", &json!({"paths": lut_paths})) { - Ok(v) => { - let profiles = v["imported"].as_array().map_or(0, Vec::len); - if profiles > 0 { - parts.push(crate::i18n::tr_format!( - "Imported {profiles} profile{} (Profile browser ▸ their groups)", - if profiles == 1 { "" } else { "s" }, - profiles = profiles - )); - } - problems.extend(v["failed"].as_array().into_iter().flatten().map(|f| { - let name = f.get(0).and_then(Value::as_str).unwrap_or_default(); - let reason = f.get(1).and_then(Value::as_str).unwrap_or_default(); - format!("{name}: {reason}") - })); - profiles_result = v; - } - Err(e) => { - problems.push(e.to_string()); - profiles_result = json!({"error": e.to_string()}); - } - } - } - let preset_paths: Vec<&String> = paths.iter().filter(|p| !p.to_ascii_lowercase().ends_with(".cube")).collect(); - let r = if preset_paths.is_empty() { - Ok(json!({"imported": [], "failed": [], "skipped": 0})) - } else { - app.session.execute("preset.import", &json!({"paths": preset_paths})).map_err(|e| e.to_string()) - }; - if let (false, Ok(v)) = (preset_paths.is_empty(), &r) { - let n = v["imported"].as_array().map_or(0, Vec::len); - let failed = v["failed"].as_array().map_or(0, Vec::len); - // Lightroom local adjustment presets land in the Masking panel's Effect menu: part of the same toast - let effects = v["effects"].as_array().map_or(0, Vec::len); - if effects > 0 { - parts.push(crate::panels::mask_effects::import_toast(v)); - } - let profile_news = !parts.is_empty() || !problems.is_empty(); - let msg = match (n, failed) { - // a zip of LUTs (or only local presets): the other half already says what happened - (0, 0) if profile_news => None, - (0, 0) => Some("No new presets".to_string()), - (n, 0) => Some(crate::i18n::tr_format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }, n = n)), - (n, f) => Some(crate::i18n::tr_format!( - "Imported {n} preset{}, {f} file{} not readable", - if n == 1 { "" } else { "s" }, - if f == 1 { "" } else { "s" }, - f = f, - n = n - )), - }; - if let Some(mut msg) = msg { - // settings with no counterpart here (the other editor's profiles, masks…) - let mut skipped: Vec<&str> = v["imported"] - .as_array() - .into_iter() - .flatten() - .flat_map(|i| i["unmapped"].as_array().into_iter().flatten()) - .filter_map(Value::as_str) - .collect(); - skipped.sort_unstable(); - skipped.dedup(); - if !skipped.is_empty() { - let names: Vec<&str> = skipped.iter().take(3).copied().collect(); - msg += &crate::i18n::tr_format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" }); - } - parts.push(msg); - } - if n > 0 { - app.ui.presets = true; - } - } - let mut msg = parts.join(" · "); - if !problems.is_empty() { - let detail = - format!("{}{}", problems.iter().take(2).cloned().collect::>().join("; "), if problems.len() > 2 { "…" } else { "" }); - if msg.is_empty() { - msg = crate::i18n::tr_format!("Import failed: {e}", e = detail); - } else { - msg += &crate::i18n::tr_format!(" · {} failed: {}", problems.len(), detail); - } - app.toast_error(&ctx, msg); - } else if !msg.is_empty() { - app.toast(&ctx, msg); - } - let r = r.map(|mut v| { - if !lut_paths.is_empty() - && let Some(o) = v.as_object_mut() - { - o.insert("profiles".into(), profiles_result); - } - v - }); - return Some(r); - } - "file.importKeywords" => { - // a keyword list file: Lightroom Classic's, Capture One's, Photo Supreme's (.utf8) - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => x.to_string(), - None => { - let req = PickRequest::file(crate::i18n::tr("Import Keywords"), crate::i18n::tr("Keyword Lists"), &["txt", "utf8"]); - match crate::pick::ask(app, id, p, "path", req, |s| s.pick_keyword_list.as_mut().map(|f| f())) { - Picked::Now(v) => match v.into_iter().next() { - Some(x) => x, - None => return Some(Ok(Value::Null)), - }, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - let r = app.session.execute("keyword.import", &json!({"path": path})).map_err(|e| e.to_string()); - match &r { - Ok(v) => { - let (added, updated) = (v["added"].as_u64().unwrap_or(0), v["updated"].as_u64().unwrap_or(0)); - let mut msg = crate::i18n::tr_format!("Added {n} keyword{}", if added == 1 { "" } else { "s" }, n = added); - if updated > 0 { - msg.push_str(&crate::i18n::tr_format!("; {n} gained synonyms", n = updated)); - } - app.toast(&ctx, msg); - } - Err(e) => app.toast(&ctx, e.clone()), - } - return Some(r); - } - "file.exportKeywords" => { - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => x.to_string(), - None => { - let name = "Keywords.txt"; - let req = PickRequest::save(crate::i18n::tr("Export Keywords"), crate::i18n::tr("Keyword Lists"), &["txt"], name); - match crate::pick::ask(app, id, p, "path", req, |s| s.save_keyword_list.as_mut().map(|f| f(name).into_iter().collect())) { - Picked::Now(v) => match v.into_iter().next() { - Some(x) => x, - None => return Some(Ok(Value::Null)), - }, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - let r = app.session.execute("keyword.export", &json!({"path": path})).map_err(|e| e.to_string()); - if let Err(e) = &r { - app.toast(&ctx, e.clone()); - } - if let Ok(v) = &r { - let n = v["keywords"].as_u64().unwrap_or(0); - let mut msg = crate::i18n::tr_format!("Exported {n} keyword{}", if n == 1 { "" } else { "s" }, n = n); - // Capture One's importer refuses ; , < > in a list: say which to rename first - let refuses: Vec<&str> = v["captureOneRefuses"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); - if !refuses.is_empty() { - msg.push_str(&crate::i18n::tr_format!( - " — Capture One won't import a list with ; , < or > in a keyword: {names}", - names = refuses.iter().take(5).copied().collect::>().join(" · ") - )); - } - // what the format can't hold (a name in brackets, a line break…) isn't in the file - let left: Vec<&str> = v["unwritable"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); - if !left.is_empty() { - msg.push_str(&crate::i18n::tr_format!( - " — left out, as a keyword list can't hold them: {names}", - // escaped: a line break in a name would break the toast - names = left.iter().take(5).map(|n| n.escape_debug().to_string()).collect::>().join(" · ") - )); - } - app.toast(&ctx, msg); - } - return Some(r); - } + "file.importPresets" => file_import_presets_command(app, ctx, id, p), + "file.importKeywords" => file_import_keywords_command(app, ctx, id, p), + "file.exportKeywords" => file_export_keywords_command(app, ctx, id, p), "dialog.saveMaskEffect" => { if app.session.active_mask.is_none() { return Some(Err("select a mask first".into())); @@ -1853,38 +1393,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => { - let name = format!("{}.lceffect", p.get("group").and_then(Value::as_str).unwrap_or("Effect Presets")); - let req = PickRequest::save( - crate::i18n::tr("Export Effect Presets"), - crate::i18n::tr("LightCraft Effect Presets"), - &["lceffect"], - name.clone(), - ); - match crate::pick::ask(app, id, p, "path", req, |s| s.save_preset_file.as_mut().map(|f| f(&name).into_iter().collect())) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - let Some(path) = path else { return Some(Ok(Value::Null)) }; - let mut params = json!({"path": path}); - for k in ["names", "group"] { - if let Some(v) = p.get(k) { - params[k] = v.clone(); - } - } - let r = app.session.execute("mask.exportEffects", ¶ms).map_err(|e| e.to_string()); - match &r { - Ok(v) => app.toast(&ctx, crate::i18n::tr_format!("Exported {} effect preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })), - Err(e) => app.toast_error(&ctx, e.clone()), - } - return Some(r); - } + "file.exportMaskEffects" => file_export_mask_effects_command(app, ctx, id, p), "file.exportPresets" => { let group = p.get("group").and_then(Value::as_str).map(str::to_string); let path = match p.get("path").and_then(Value::as_str) { @@ -1909,71 +1418,20 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - let paths: Vec = match p.get("paths").and_then(Value::as_array) { - Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), - None => { - let req = PickRequest::files( - Some(crate::i18n::tr("Import Point Curve Presets").into()), - crate::i18n::tr("Point Curve Presets"), - crate::pick::CURVE_PRESET_EXTENSIONS, - ); - match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_curve_preset_files.as_mut().map(|f| f())) { - Picked::Now(paths) => paths, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - if paths.is_empty() { - return Some(Ok(Value::Null)); - } - let r = app.session.execute("curve.importPresets", &json!({"paths": paths})).map_err(|e| e.to_string()); - if let Ok(v) = &r { - let n = v["imported"].as_array().map_or(0, Vec::len); - let failed = v["failed"].as_array().map_or(0, Vec::len); - let mut msg = crate::i18n::tr_format!("Imported {n} point curve preset{}", if n == 1 { "" } else { "s" }, n = n); - if failed > 0 { - msg += &crate::i18n::tr_format!(", {failed} file{} not readable", if failed == 1 { "" } else { "s" }, failed = failed); - } - app.toast(&ctx, msg); - } - return Some(r); - } - "file.exportCurvePresets" => { - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => { - let req = PickRequest::save( - crate::i18n::tr("Export Point Curve Presets"), - crate::i18n::tr("Point Curve Presets"), - &["lccurve"], - "Point Curves.lccurve", - ); - match crate::pick::ask(app, id, p, "path", req, |s| { - s.save_curve_preset_file.as_mut().map(|f| f("Point Curves.lccurve").into_iter().collect()) - }) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - let Some(path) = path else { return Some(Ok(Value::Null)) }; - let mut params = json!({"path": path}); - if let Some(n) = p.get("names") { - params["names"] = n.clone(); - } - let r = app.session.execute("curve.exportPresets", ¶ms).map_err(|e| e.to_string()); - if let Ok(v) = &r { - app.toast(&ctx, crate::i18n::tr_format!("Exported {} point curve preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })); - } - return Some(r); + r } + "file.importCurvePresets" => file_import_curve_presets_command(app, ctx, id, p), + _ => return None, + }; + Some(r) +} + +/// Part 12 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_12(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { + "file.exportCurvePresets" => file_export_curve_presets_command(app, ctx, id, p), "app.export" => crate::control::export_active(app, p), "app.showInFinder" => show_in_finder(app), "app.showInFolders" => show_in_folders(app, p.get("id").and_then(Value::as_u64).map(lightcraft_catalog::PhotoId)), @@ -1990,3 +1448,687 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Result { + if app.ui.dialog.is_some() { + app.ui.dialog = None; + } else if app.ui.lights_out != crate::state::LightsOut::On { + app.ui.lights_out = crate::state::LightsOut::On; + } else if app.ui.keyword_painter.is_some() { + app.ui.keyword_painter = None; + } else if app.ui.fullscreen { + // Through `slideshow::end`: it also releases the held slide textures. Clearing the + // two fields directly left every rendered slide in the renderer. + crate::panels::slideshow::end(app); + } else if !app.ui.tool.is_empty() { + app.ui.tool.clear(); + } else if app.ui.right == RightPanel::Crop && app.ui.view == ViewMode::Detail { + // Esc leaves the crop tool (the crop stays), as C does; the next Esc goes back + panel(app, ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); + } else if app.ui.view == ViewMode::People && app.ui.person_page.is_some() { + app.ui.person_page = None; + } else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey | ViewMode::Reference) { + app.ui.view = ViewMode::Detail; + } else if app.ui.view == ViewMode::Detail { + // a photo opened from a person's page (or the unnamed faces) goes back there, any other to the grid + match app.ui.person_from.take() { + Some((name, photo)) if app.session.active().is_some_and(|a| a.0 == photo) => { + app.ui.view = ViewMode::People; + app.ui.person_page = name; + } + _ => app.ui.view = ViewMode::PhotoGrid, + } + } + Ok(Value::Null) +} + +/// The `view.viewOptions` command. +fn view_view_options_command(app: &mut LightcraftApp, p: &Value) -> Result { + // `info1` / `info2` set the lines, `briefly` the Info that Show Briefly When Photo Changes + // shows and `loupeMessages` Show message when loading or rendering photos, for an agent; + // without any of them the window opens + let mut info = app.ui.settings.loupe_info; + let mut set = false; + // `loupeMessages` sets Show message when loading or rendering photos + let messages = match p.get("loupeMessages") { + None => app.ui.settings.loupe_messages, + Some(v) => match v.as_bool() { + Some(on) => { + set = true; + on + } + None => return Err("`loupeMessages`: true|false".into()), + }, + }; + for (key, lines) in [("info1", &mut info.info1), ("info2", &mut info.info2)] { + let Some(v) = p.get(key) else { continue }; + match serde_json::from_value(v.clone()) { + Ok(v) => *lines = v, + Err(_) => { + return Err(format!("`{key}`: three of none|fileName|captureDate|dimensions|exposure|camera|lens|cameraAndLens")); + } + } + set = true; + } + if let Some(v) = p.get("briefly") { + match serde_json::from_value(v.clone()) { + Ok(v) => info.briefly = v, + Err(_) => return Err("`briefly`: off|basic|exposure (neither, Info 1 or Info 2)".into()), + } + set = true; + } + if set { + app.ui.settings.loupe_info = info; + app.ui.settings.loupe_messages = messages; + } else { + app.ui.dialog = Some(Dialog::ViewOptions); + } + Ok(json!({"loupeInfo": app.ui.settings.loupe_info, "loupeMessages": app.ui.settings.loupe_messages})) +} + +/// The `view.softProof` command. +fn view_soft_proof_command(app: &mut LightcraftApp, _id: &str, p: &Value) -> Result { + // {on?, space?, profile?: ICC path | null, intent?: perceptual|relative, paperInk?, + // destWarning?, displayWarning?}; no params toggles + let has = |k: &str| p.get(k).is_some(); + if let Some(s) = p.get("space").and_then(Value::as_str) { + match lightcraft_engine::pipeline::OutputSpace::parse(s) { + Some(sp) => { + app.ui.proof.space = sp; + // a standard space replaces a device profile (unless one is given too) + app.ui.proof_profile = None; + } + None => return Err(format!("view.softProof: unknown space {s:?} (srgb|displayP3|adobeRgb|proPhoto|rec2020)")), + } + } + if let Some(i) = p.get("intent") { + match i.as_str().and_then(lightcraft_engine::proof::ProofIntent::parse) { + Some(i) => app.ui.proof_intent = i, + None => return Err("view.softProof: intent must be perceptual or relative".into()), + } + } + if let Some(b) = p.get("paperInk").and_then(Value::as_bool) { + app.ui.proof_paper_ink = b; + } + let path = match p.get("profile") { + Some(Value::String(s)) => Some(s.clone()), + Some(Value::Null) => { + app.ui.proof_profile = None; + None + } + Some(_) => return Err("view.softProof: profile must be an ICC file path or null".into()), + None => app.ui.proof_profile.as_ref().map(|(f, _)| f.clone()), + }; + match path { + Some(path) => match lightcraft_engine::proof::load(&path, app.ui.proof_intent, app.ui.proof_paper_ink) { + Ok(pp) => { + app.ui.proof.profile = pp.id; + app.ui.proof_profile = Some((path, pp.name)); + } + Err(e) => return Err(format!("view.softProof: {e}")), + }, + None => app.ui.proof.profile = 0, + } + if let Some(b) = p.get("destWarning").and_then(Value::as_bool) { + app.ui.proof.dest_warning = b; + } + if let Some(b) = p.get("displayWarning").and_then(Value::as_bool) { + app.ui.proof.display_warning = b; + } + app.ui.soft_proof = match p.get("on").and_then(Value::as_bool) { + Some(b) => b, + None if ["space", "destWarning", "displayWarning", "profile", "intent", "paperInk"].iter().any(|k| has(k)) => app.ui.soft_proof, + None => !app.ui.soft_proof, + }; + if app.ui.soft_proof && !matches!(app.ui.view, ViewMode::Detail | ViewMode::Reference) { + app.ui.view = ViewMode::Detail; + } + let pr = app.ui.proof; + let profile = app.ui.proof_profile.as_ref().map(|(f, n)| json!({"path": f, "name": n})); + Ok(json!({"on": app.ui.soft_proof, "space": pr.space, "profile": profile, "intent": app.ui.proof_intent.name(), + "paperInk": app.ui.proof_paper_ink, "destWarning": pr.dest_warning, "displayWarning": pr.display_warning})) +} + +/// The `dialog.smartAlbum` command. +fn dialog_smart_album_command(app: &mut LightcraftApp, _id: &str, p: &Value) -> Result { + // {id?}: edit that smart album's rules; without: a new one starting at Rating ≥ 3 + let album = p + .get("id") + .and_then(Value::as_u64) + .and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id)).filter(|a| a.is_smart()).cloned()); + app.ui.dialog = Some(match album { + Some(a) => Dialog::SmartRules { + id: Some(a.id.0), + name: a.name.clone(), + // older rules read as they always matched (RuleSet::upgrade) + rules: a + .smart + .as_ref() + .and_then(|f| f.rule_set.clone()) + .map(|mut r| { + r.upgrade(); + r + }) + .unwrap_or_default(), + parent: None, + }, + None => Dialog::SmartRules { + id: None, + name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), + rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, + parent: parent_param(p), + }, + }); + Ok(Value::Null) +} + +/// The `app.systemInfo` command. +fn app_system_info_command(app: &mut LightcraftApp, p: &Value) -> Result { + let info = app.session.execute("library.info", &json!({})).unwrap_or_default(); + let gpu = (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten(); + let mb = |b: u64| format!("{:.0} MB", b as f64 / (1u64 << 20) as f64); + let mut rows = vec![ + ("Version".to_string(), env!("CARGO_PKG_VERSION").to_string()), + ("System".to_string(), format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH)), + ("CPU threads".to_string(), std::thread::available_parallelism().map(|n| n.get().to_string()).unwrap_or_else(|_| "?".into())), + ( + "GPU".to_string(), + gpu.unwrap_or_else(|| match lightcraft_engine::gpu::unavailable_reason() { + Some(why) => crate::i18n::tr_format!("none (CPU rendering): {why}", why = why), + None => "none (CPU rendering)".into(), + }), + ), + ("GPU rendering".to_string(), if app.ui.settings.gpu { "on".into() } else { "off".into() }), + ("Memory budget".to_string(), mb(lightcraft_engine::memory::default_budget() as u64)), + ( + "Preview size".to_string(), + match app.ui.settings.preview_limit { + 0 => "automatic".to_string(), + n => format!("{n} px"), + }, + ), + ("Photos".to_string(), info["photos"].to_string()), + ("Albums".to_string(), info["albums"].to_string()), + ]; + if let Some(dir) = info["dir"].as_str().or(info["path"].as_str()) { + rows.push(("Library".into(), dir.to_string())); + } + rows.push(("Frame time".into(), format!("{:.1} ms ({:.0} fps)", app.perf.frame_ms, app.perf.fps))); + rows.push(( + "Frame update".into(), + format!("{:.1} ms (logic {:.1} ms; slowest {:.0} ms)", app.perf.update_ms, app.perf.logic_ms, app.perf.max_update_ms), + )); + rows.push(("Last loupe render".into(), format!("{:.0} ms", app.renderer.last_main_ms))); + if let Some(f) = lightcraft_engine::gpu::last_fallback() { + rows.push(("Last GPU fallback".into(), f)); + } + let r = json!(rows.iter().map(|(k, v)| json!({"label": k, "value": v})).collect::>()); + if p.get("open").and_then(Value::as_bool).unwrap_or(true) { + app.ui.dialog = Some(Dialog::SystemInfo { rows }); + } + Ok(r) +} + +/// `file.findMissing {within?}`: the folder the search is limited to, if any. +fn find_missing_within(p: &Value) -> Result, String> { + match p.get("within") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(path)) if !path.is_empty() => Ok(Some(path.clone())), + Some(Value::String(_)) => Err("file.findMissing: `within` must not be empty".into()), + Some(value) => Err(format!("file.findMissing: `within` must be a folder path, not {value}")), + } +} + +/// `file.findMissing {timeout?}`: how long `wait` waits for the search (ten minutes when omitted). +fn find_missing_timeout(p: &Value) -> Result { + let n = match p.get("timeout") { + None | Some(Value::Null) => return Ok(std::time::Duration::from_secs(600)), + Some(Value::Number(n)) => n, + Some(other) => return Err(format!("file.findMissing: `timeout` must be a number of seconds, not {other}")), + }; + let s = n.as_f64().ok_or("file.findMissing: `timeout` must be a number of seconds")?; + if !s.is_finite() || s < 0.0 { + return Err("file.findMissing: `timeout` must be a non-negative number of seconds".into()); + } + std::time::Duration::try_from_secs_f64(s).map_err(|_| "file.findMissing: `timeout` is too large".to_string()) +} + +/// The toast for a finished search, and the result it leaves for a caller that waits. +fn find_missing_done(app: &mut LightcraftApp, ctx: &egui::Context, plan: Result) { + // relinked under the session as it is now: photos relinked meanwhile and files + // now in use are skipped + let r = plan.and_then(|plan| app.run("library.findMissing", plan.to_json())); + match r { + Ok(v) => { + let n = v["found"].as_array().map_or(0, Vec::len); + let left = v["missing"].as_u64().unwrap_or(0); + let unsure = v["ambiguous"].as_array().map_or(0, Vec::len); + let unsure = if unsure > 0 { + crate::i18n::tr_format!(" ({unsure} with several look-alike files: use Locate)", unsure = unsure) + } else { + String::new() + }; + app.toast( + ctx, + crate::i18n::tr_format!( + "Found {n} missing photo{}; {left} still missing{unsure}", + if n == 1 { "" } else { "s" }, + left = left, + n = n, + unsure = unsure + ), + ); + app.ui.last_find_missing = Some(v.clone()); + app.ui.last_find_missing_result = Some(Ok(v)); + } + Err(e) => { + app.toast(ctx, e.clone()); + app.ui.last_find_missing_result = Some(Err(e)); + } + } +} + +/// The `file.findMissing` command. +fn file_find_missing_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Result { + let within = find_missing_within(p)?; + let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false); + let timeout = find_missing_timeout(p)?; + let title = if within.is_some() { "Locate Missing Files…" } else { "Find Missing Photos…" }; + let folder = match p.get("folder").and_then(Value::as_str) { + Some(f) => Some(f.to_string()), + None => match crate::pick::ask(app, id, p, "folder", PickRequest::folder(crate::i18n::tr(title)), |s| { + s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x]) + }) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + }, + }; + let Some(folder) = folder else { return Ok(Value::Null) }; + // the search (checking every photo's file, walking the folder) runs on a worker thread; + // the relinking happens back here, as one undo step + const LABEL: &str = "Find Missing Photos"; + if app.tasks.is_running(LABEL) { + return Err("Find Missing Photos is already searching".into()); + } + let mut candidates = lightcraft_engine::cmd::missing::find_candidates(&app.session.catalog); + if let Some(within) = within { + candidates.retain(|candidate| lightcraft_catalog::query::folder_within(&candidate.path, &within)); + } + #[cfg(test)] + let test_delay = app.ui.find_missing_test_delay; + let work = move || { + #[cfg(test)] + if let Some(d) = test_delay { + std::thread::sleep(d); + } + lightcraft_engine::cmd::missing::plan_find_missing(&candidates, &folder) + }; + app.ui.last_find_missing = None; + app.ui.last_find_missing_result = None; + crate::tasks::spawn(app, LABEL, Some("findMissing"), work, find_missing_done)?; + if !wait { + return Ok(json!({"background": true})); + } + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + if !crate::tasks::wait_for(app, &ctx, LABEL, timeout) { + return Err("Find Missing Photos: search timed out (search continues in the background)".into()); + } + app.ui.last_find_missing_result.take().unwrap_or_else(|| Err("Find Missing Photos: worker terminated unexpectedly".into())) +} + +/// The `photo.tagFromTracklog` command. +fn photo_tag_from_tracklog_command(app: &mut LightcraftApp, _ctx: &egui::Context, id: &str, p: &Value) -> Result { + // a GPX file → GPS for the selected photos by capture time (one undo step) + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let req = PickRequest::file(crate::i18n::tr("Auto-Tag from Tracklog"), crate::i18n::tr("GPS Track Log"), &["gpx"]); + match crate::pick::ask(app, id, p, "path", req, |s| s.pick_tracklog.as_mut().map(|f| f())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + } + } + }; + let Some(path) = path else { return Ok(Value::Null) }; + let mut params = p.as_object().cloned().unwrap_or_default(); + params.insert("path".into(), json!(path)); + let ask_zone = !params.contains_key("offset"); + let params = Value::Object(params); + if ask_zone { + // GPX times are UTC, camera clocks are local: ask for the camera's zone, then come back here + crate::panels::dialogs::prompt( + app, + "Auto-Tag from Tracklog", + "Camera time zone, e.g. -07:00 (empty: UTC)", + "", + "photo.tagFromTracklog", + params, + "offset", + ); + return Ok(Value::Null); + } + let r = app.run("photo.autoTagTracklog", params); + if let Ok(v) = &r { + let n = v["tagged"].as_u64().unwrap_or(0); + let sk = &v["skipped"]; + let mut msg = crate::i18n::tr_format!("Tagged {n} photo{} from the tracklog", if n == 1 { "" } else { "s" }, n = n); + let outside = sk["outside"].as_u64().unwrap_or(0); + if outside > 0 { + msg += &crate::i18n::tr_format!("; {outside} outside its time range", outside = outside); + } + let kept = sk["hasGps"].as_u64().unwrap_or(0); + if kept > 0 { + msg += &crate::i18n::tr_format!("; {kept} already had a location", kept = kept); + } + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + app.toast(&ctx, msg); + } + r +} + +/// The `file.importPresets` command. +fn file_import_presets_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { + let paths = match p.get("paths").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), + None => { + let req = PickRequest::files( + Some(crate::i18n::tr("Import Presets").into()), + crate::i18n::tr("Presets & Profiles"), + crate::pick::PRESET_EXTENSIONS, + ); + match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_preset_files.as_mut().map(|f| f())) { + Picked::Now(paths) => paths, + Picked::Later => return Ok(Value::Null), + Picked::Unavailable => return Err("no file dialog on this platform".into()), + } + } + }; + if paths.is_empty() { + return Ok(Value::Null); + } + // .cube LUTs (and those inside zips / folders) become profiles + let lut_paths: Vec<&String> = paths + .iter() + .filter(|p| { + let l = p.to_ascii_lowercase(); + l.ends_with(".cube") || l.ends_with(".zip") || std::path::Path::new(p.as_str()).is_dir() + }) + .collect(); + // one toast for both halves (a second toast would replace the first) + let mut parts: Vec = Vec::new(); + // `name: reason` for each file that couldn't be imported + let mut problems: Vec = Vec::new(); + let mut profiles_result = Value::Null; + if !lut_paths.is_empty() { + match app.session.execute("profile.import", &json!({"paths": lut_paths})) { + Ok(v) => { + let profiles = v["imported"].as_array().map_or(0, Vec::len); + if profiles > 0 { + parts.push(crate::i18n::tr_format!( + "Imported {profiles} profile{} (Profile browser ▸ their groups)", + if profiles == 1 { "" } else { "s" }, + profiles = profiles + )); + } + problems.extend(v["failed"].as_array().into_iter().flatten().map(|f| { + let name = f.get(0).and_then(Value::as_str).unwrap_or_default(); + let reason = f.get(1).and_then(Value::as_str).unwrap_or_default(); + format!("{name}: {reason}") + })); + profiles_result = v; + } + Err(e) => { + problems.push(e.to_string()); + profiles_result = json!({"error": e.to_string()}); + } + } + } + let preset_paths: Vec<&String> = paths.iter().filter(|p| !p.to_ascii_lowercase().ends_with(".cube")).collect(); + let r = if preset_paths.is_empty() { + Ok(json!({"imported": [], "failed": [], "skipped": 0})) + } else { + app.session.execute("preset.import", &json!({"paths": preset_paths})).map_err(|e| e.to_string()) + }; + if let (false, Ok(v)) = (preset_paths.is_empty(), &r) { + let n = v["imported"].as_array().map_or(0, Vec::len); + let failed = v["failed"].as_array().map_or(0, Vec::len); + // Lightroom local adjustment presets land in the Masking panel's Effect menu: part of the same toast + let effects = v["effects"].as_array().map_or(0, Vec::len); + if effects > 0 { + parts.push(crate::panels::mask_effects::import_toast(v)); + } + let profile_news = !parts.is_empty() || !problems.is_empty(); + let msg = match (n, failed) { + // a zip of LUTs (or only local presets): the other half already says what happened + (0, 0) if profile_news => None, + (0, 0) => Some("No new presets".to_string()), + (n, 0) => Some(crate::i18n::tr_format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }, n = n)), + (n, f) => Some(crate::i18n::tr_format!( + "Imported {n} preset{}, {f} file{} not readable", + if n == 1 { "" } else { "s" }, + if f == 1 { "" } else { "s" }, + f = f, + n = n + )), + }; + if let Some(mut msg) = msg { + // settings with no counterpart here (the other editor's profiles, masks…) + let mut skipped: Vec<&str> = v["imported"] + .as_array() + .into_iter() + .flatten() + .flat_map(|i| i["unmapped"].as_array().into_iter().flatten()) + .filter_map(Value::as_str) + .collect(); + skipped.sort_unstable(); + skipped.dedup(); + if !skipped.is_empty() { + let names: Vec<&str> = skipped.iter().take(3).copied().collect(); + msg += &crate::i18n::tr_format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" }); + } + parts.push(msg); + } + if n > 0 { + app.ui.presets = true; + } + } + let mut msg = parts.join(" · "); + if !problems.is_empty() { + let detail = format!("{}{}", problems.iter().take(2).cloned().collect::>().join("; "), if problems.len() > 2 { "…" } else { "" }); + if msg.is_empty() { + msg = crate::i18n::tr_format!("Import failed: {e}", e = detail); + } else { + msg += &crate::i18n::tr_format!(" · {} failed: {}", problems.len(), detail); + } + app.toast_error(ctx, msg); + } else if !msg.is_empty() { + app.toast(ctx, msg); + } + + r.map(|mut v| { + if !lut_paths.is_empty() + && let Some(o) = v.as_object_mut() + { + o.insert("profiles".into(), profiles_result); + } + v + }) +} + +/// The `file.importKeywords` command. +fn file_import_keywords_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { + // a keyword list file: Lightroom Classic's, Capture One's, Photo Supreme's (.utf8) + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => x.to_string(), + None => { + let req = PickRequest::file(crate::i18n::tr("Import Keywords"), crate::i18n::tr("Keyword Lists"), &["txt", "utf8"]); + match crate::pick::ask(app, id, p, "path", req, |s| s.pick_keyword_list.as_mut().map(|f| f())) { + Picked::Now(v) => match v.into_iter().next() { + Some(x) => x, + None => return Ok(Value::Null), + }, + Picked::Later => return Ok(Value::Null), + Picked::Unavailable => return Err("no file dialog on this platform".into()), + } + } + }; + let r = app.session.execute("keyword.import", &json!({"path": path})).map_err(|e| e.to_string()); + match &r { + Ok(v) => { + let (added, updated) = (v["added"].as_u64().unwrap_or(0), v["updated"].as_u64().unwrap_or(0)); + let mut msg = crate::i18n::tr_format!("Added {n} keyword{}", if added == 1 { "" } else { "s" }, n = added); + if updated > 0 { + msg.push_str(&crate::i18n::tr_format!("; {n} gained synonyms", n = updated)); + } + app.toast(ctx, msg); + } + Err(e) => app.toast(ctx, e.clone()), + } + r +} + +/// The `file.exportKeywords` command. +fn file_export_keywords_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => x.to_string(), + None => { + let name = "Keywords.txt"; + let req = PickRequest::save(crate::i18n::tr("Export Keywords"), crate::i18n::tr("Keyword Lists"), &["txt"], name); + match crate::pick::ask(app, id, p, "path", req, |s| s.save_keyword_list.as_mut().map(|f| f(name).into_iter().collect())) { + Picked::Now(v) => match v.into_iter().next() { + Some(x) => x, + None => return Ok(Value::Null), + }, + Picked::Later => return Ok(Value::Null), + Picked::Unavailable => return Err("no file dialog on this platform".into()), + } + } + }; + let r = app.session.execute("keyword.export", &json!({"path": path})).map_err(|e| e.to_string()); + if let Err(e) = &r { + app.toast(ctx, e.clone()); + } + if let Ok(v) = &r { + let n = v["keywords"].as_u64().unwrap_or(0); + let mut msg = crate::i18n::tr_format!("Exported {n} keyword{}", if n == 1 { "" } else { "s" }, n = n); + // Capture One's importer refuses ; , < > in a list: say which to rename first + let refuses: Vec<&str> = v["captureOneRefuses"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + if !refuses.is_empty() { + msg.push_str(&crate::i18n::tr_format!( + " — Capture One won't import a list with ; , < or > in a keyword: {names}", + names = refuses.iter().take(5).copied().collect::>().join(" · ") + )); + } + // what the format can't hold (a name in brackets, a line break…) isn't in the file + let left: Vec<&str> = v["unwritable"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + if !left.is_empty() { + msg.push_str(&crate::i18n::tr_format!( + " — left out, as a keyword list can't hold them: {names}", + // escaped: a line break in a name would break the toast + names = left.iter().take(5).map(|n| n.escape_debug().to_string()).collect::>().join(" · ") + )); + } + app.toast(ctx, msg); + } + r +} + +/// The `file.exportMaskEffects` command. +fn file_export_mask_effects_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let name = format!("{}.lceffect", p.get("group").and_then(Value::as_str).unwrap_or("Effect Presets")); + let req = PickRequest::save( + crate::i18n::tr("Export Effect Presets"), + crate::i18n::tr("LightCraft Effect Presets"), + &["lceffect"], + name.clone(), + ); + match crate::pick::ask(app, id, p, "path", req, |s| s.save_preset_file.as_mut().map(|f| f(&name).into_iter().collect())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later => return Ok(Value::Null), + Picked::Unavailable => return Err("no file dialog on this platform".into()), + } + } + }; + let Some(path) = path else { return Ok(Value::Null) }; + let mut params = json!({"path": path}); + for k in ["names", "group"] { + if let Some(v) = p.get(k) { + params[k] = v.clone(); + } + } + let r = app.session.execute("mask.exportEffects", ¶ms).map_err(|e| e.to_string()); + match &r { + Ok(v) => app.toast(ctx, crate::i18n::tr_format!("Exported {} effect preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })), + Err(e) => app.toast_error(ctx, e.clone()), + } + r +} + +/// The `file.importCurvePresets` command. +fn file_import_curve_presets_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { + let paths: Vec = match p.get("paths").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), + None => { + let req = PickRequest::files( + Some(crate::i18n::tr("Import Point Curve Presets").into()), + crate::i18n::tr("Point Curve Presets"), + crate::pick::CURVE_PRESET_EXTENSIONS, + ); + match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_curve_preset_files.as_mut().map(|f| f())) { + Picked::Now(paths) => paths, + Picked::Later => return Ok(Value::Null), + Picked::Unavailable => return Err("no file dialog on this platform".into()), + } + } + }; + if paths.is_empty() { + return Ok(Value::Null); + } + let r = app.session.execute("curve.importPresets", &json!({"paths": paths})).map_err(|e| e.to_string()); + if let Ok(v) = &r { + let n = v["imported"].as_array().map_or(0, Vec::len); + let failed = v["failed"].as_array().map_or(0, Vec::len); + let mut msg = crate::i18n::tr_format!("Imported {n} point curve preset{}", if n == 1 { "" } else { "s" }, n = n); + if failed > 0 { + msg += &crate::i18n::tr_format!(", {failed} file{} not readable", if failed == 1 { "" } else { "s" }, failed = failed); + } + app.toast(ctx, msg); + } + r +} + +/// The `file.exportCurvePresets` command. +fn file_export_curve_presets_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let req = PickRequest::save( + crate::i18n::tr("Export Point Curve Presets"), + crate::i18n::tr("Point Curve Presets"), + &["lccurve"], + "Point Curves.lccurve", + ); + match crate::pick::ask(app, id, p, "path", req, |s| { + s.save_curve_preset_file.as_mut().map(|f| f("Point Curves.lccurve").into_iter().collect()) + }) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later => return Ok(Value::Null), + Picked::Unavailable => return Err("no file dialog on this platform".into()), + } + } + }; + let Some(path) = path else { return Ok(Value::Null) }; + let mut params = json!({"path": path}); + if let Some(n) = p.get("names") { + params["names"] = n.clone(); + } + let r = app.session.execute("curve.exportPresets", ¶ms).map_err(|e| e.to_string()); + if let Ok(v) = &r { + app.toast(ctx, crate::i18n::tr_format!("Exported {} point curve preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })); + } + r +} From 2f3002bc5cc3873bec404919876ea9f34df29a6d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 20:18:02 +0000 Subject: [PATCH 26/39] refactor(ui-egui): split preset import and find-missing commands --- crates/ui-egui/src/menus/run.rs | 337 ++++++++++++++++++-------------- 1 file changed, 187 insertions(+), 150 deletions(-) diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 52e6006eb..ed45cbcb4 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -1324,24 +1324,7 @@ fn ui_commands_11(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Va } "file.findMissing" => file_find_missing_command(app, id, p), "photo.tagFromTracklog" => photo_tag_from_tracklog_command(app, ctx, id, p), - "photo.locate" => { - let Some(id) = app.session.active() else { return Some(Err("no photo selected".into())) }; - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => { - let req = - PickRequest::file(crate::i18n::tr("Locate Missing File…"), crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS); - match crate::pick::ask(app, "photo.locate", p, "path", req, |s| s.pick_files.as_mut().map(|f| f())) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later | Picked::Unavailable => None, - } - } - }; - match path { - Some(path) => app.run("photo.relink", json!({"id": id.0, "path": path})), - None => Ok(Value::Null), - } - } + "photo.locate" => photo_locate_command(app, p), "file.addFromDevice" => { // a camera / card: review its DCIM folder, copying into the library by default let path = match p.get("path").and_then(Value::as_str) { @@ -1729,6 +1712,25 @@ fn find_missing_done(app: &mut LightcraftApp, ctx: &egui::Context, plan: Result< } } +/// The `photo.locate` command. +fn photo_locate_command(app: &mut LightcraftApp, p: &Value) -> Result { + let Some(id) = app.session.active() else { return Err("no photo selected".into()) }; + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let req = PickRequest::file(crate::i18n::tr("Locate Missing File…"), crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS); + match crate::pick::ask(app, "photo.locate", p, "path", req, |s| s.pick_files.as_mut().map(|f| f())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + } + } + }; + match path { + Some(path) => app.run("photo.relink", json!({"id": id.0, "path": path})), + None => Ok(Value::Null), + } +} + /// The `file.findMissing` command. fn file_find_missing_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Result { let within = find_missing_within(p)?; @@ -1827,27 +1829,152 @@ fn photo_tag_from_tracklog_command(app: &mut LightcraftApp, _ctx: &egui::Context r } -/// The `file.importPresets` command. -fn file_import_presets_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { - let paths = match p.get("paths").and_then(Value::as_array) { +/// The files a command names in `paths`, else the ones picked in a dialog. `Err` is the command's own answer when +/// there is nothing to import (the dialog is still open or was cancelled, or none is available). +fn paths_or_pick( + app: &mut LightcraftApp, + id: &str, + p: &Value, + request: PickRequest, + now: impl FnOnce(&mut crate::services::Services) -> Option>, +) -> Result, Result> { + let paths: Vec = match p.get("paths").and_then(Value::as_array) { Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), - None => { - let req = PickRequest::files( - Some(crate::i18n::tr("Import Presets").into()), - crate::i18n::tr("Presets & Profiles"), - crate::pick::PRESET_EXTENSIONS, - ); - match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_preset_files.as_mut().map(|f| f())) { - Picked::Now(paths) => paths, - Picked::Later => return Ok(Value::Null), - Picked::Unavailable => return Err("no file dialog on this platform".into()), - } - } + None => match crate::pick::ask(app, id, p, "paths", request, now) { + Picked::Now(paths) => paths, + Picked::Later => return Err(Ok(Value::Null)), + Picked::Unavailable => return Err(Err("no file dialog on this platform".into())), + }, }; if paths.is_empty() { - return Ok(Value::Null); + return Err(Ok(Value::Null)); } - // .cube LUTs (and those inside zips / folders) become profiles + Ok(paths) +} + +/// What an import of presets and profiles has to say: one toast for both halves (a second toast would replace the first). +#[derive(Default)] +struct ImportNews { + parts: Vec, + /// `name: reason` for each file that couldn't be imported + problems: Vec, +} + +/// `name: reason` for each `[name, reason]` of a `failed` list. +fn failed_files(v: &Value) -> impl Iterator + '_ { + v["failed"].as_array().into_iter().flatten().map(|f| { + let name = f.get(0).and_then(Value::as_str).unwrap_or_default(); + let reason = f.get(1).and_then(Value::as_str).unwrap_or_default(); + format!("{name}: {reason}") + }) +} + +/// `s` for a count other than one. +fn plural(n: usize) -> &'static str { + if n == 1 { "" } else { "s" } +} + +/// Import the `.cube` LUTs (and those inside zips / folders) as profiles; the answer to report back. +fn import_luts(app: &mut LightcraftApp, lut_paths: &[&String], news: &mut ImportNews) -> Value { + match app.session.execute("profile.import", &json!({"paths": lut_paths})) { + Ok(v) => { + let profiles = v["imported"].as_array().map_or(0, Vec::len); + if profiles > 0 { + news.parts.push(crate::i18n::tr_format!( + "Imported {profiles} profile{} (Profile browser ▸ their groups)", + plural(profiles), + profiles = profiles + )); + } + news.problems.extend(failed_files(&v)); + v + } + Err(e) => { + news.problems.push(e.to_string()); + json!({"error": e.to_string()}) + } + } +} + +/// The settings of imported presets that have no counterpart here (the other editor's profiles, masks…), as a note. +fn not_carried_over(v: &Value) -> String { + let mut skipped: Vec<&str> = v["imported"] + .as_array() + .into_iter() + .flatten() + .flat_map(|i| i["unmapped"].as_array().into_iter().flatten()) + .filter_map(Value::as_str) + .collect(); + skipped.sort_unstable(); + skipped.dedup(); + if skipped.is_empty() { + return String::new(); + } + let names: Vec<&str> = skipped.iter().take(3).copied().collect(); + crate::i18n::tr_format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" }) +} + +/// The sentence for the presets of an import (none when the other half already says what happened). +fn presets_sentence(v: &Value, profile_news: bool) -> Option { + let n = v["imported"].as_array().map_or(0, Vec::len); + let failed = v["failed"].as_array().map_or(0, Vec::len); + let msg = match (n, failed) { + // a zip of LUTs (or only local presets): the other half already says what happened + (0, 0) if profile_news => return None, + (0, 0) => "No new presets".to_string(), + (n, 0) => crate::i18n::tr_format!("Imported {n} preset{}", plural(n), n = n), + (n, f) => crate::i18n::tr_format!("Imported {n} preset{}, {f} file{} not readable", plural(n), plural(f), f = f, n = n), + }; + Some(msg + ¬_carried_over(v)) +} + +/// Import the preset files; adds to what `news` reports. +fn import_preset_files(app: &mut LightcraftApp, preset_paths: &[&String], news: &mut ImportNews) -> Result { + if preset_paths.is_empty() { + return Ok(json!({"imported": [], "failed": [], "skipped": 0})); + } + let r = app.session.execute("preset.import", &json!({"paths": preset_paths})).map_err(|e| e.to_string()); + if let Ok(v) = &r { + // Lightroom local adjustment presets land in the Masking panel's Effect menu: part of the same toast + if v["effects"].as_array().is_some_and(|e| !e.is_empty()) { + news.parts.push(crate::panels::mask_effects::import_toast(v)); + } + let profile_news = !news.parts.is_empty() || !news.problems.is_empty(); + news.parts.extend(presets_sentence(v, profile_news)); + if v["imported"].as_array().is_some_and(|i| !i.is_empty()) { + app.ui.presets = true; + } + } + r +} + +/// Show what an import did: errors if any file failed, else the parts. +fn toast_import_news(app: &mut LightcraftApp, ctx: &egui::Context, news: &ImportNews) { + let mut msg = news.parts.join(" · "); + if news.problems.is_empty() { + if !msg.is_empty() { + app.toast(ctx, msg); + } + return; + } + let more = if news.problems.len() > 2 { "…" } else { "" }; + let detail = format!("{}{more}", news.problems.iter().take(2).cloned().collect::>().join("; ")); + if msg.is_empty() { + msg = crate::i18n::tr_format!("Import failed: {e}", e = detail); + } else { + msg += &crate::i18n::tr_format!(" · {} failed: {}", news.problems.len(), detail); + } + app.toast_error(ctx, msg); +} + +/// The `file.importPresets` command. +fn file_import_presets_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { + let request = + PickRequest::files(Some(crate::i18n::tr("Import Presets").into()), crate::i18n::tr("Presets & Profiles"), crate::pick::PRESET_EXTENSIONS); + let paths = match paths_or_pick(app, id, p, request, |s| s.pick_preset_files.as_mut().map(|f| f())) { + Ok(paths) => paths, + Err(answer) => return answer, + }; let lut_paths: Vec<&String> = paths .iter() .filter(|p| { @@ -1855,102 +1982,16 @@ fn file_import_presets_command(app: &mut LightcraftApp, ctx: &egui::Context, id: l.ends_with(".cube") || l.ends_with(".zip") || std::path::Path::new(p.as_str()).is_dir() }) .collect(); - // one toast for both halves (a second toast would replace the first) - let mut parts: Vec = Vec::new(); - // `name: reason` for each file that couldn't be imported - let mut problems: Vec = Vec::new(); - let mut profiles_result = Value::Null; - if !lut_paths.is_empty() { - match app.session.execute("profile.import", &json!({"paths": lut_paths})) { - Ok(v) => { - let profiles = v["imported"].as_array().map_or(0, Vec::len); - if profiles > 0 { - parts.push(crate::i18n::tr_format!( - "Imported {profiles} profile{} (Profile browser ▸ their groups)", - if profiles == 1 { "" } else { "s" }, - profiles = profiles - )); - } - problems.extend(v["failed"].as_array().into_iter().flatten().map(|f| { - let name = f.get(0).and_then(Value::as_str).unwrap_or_default(); - let reason = f.get(1).and_then(Value::as_str).unwrap_or_default(); - format!("{name}: {reason}") - })); - profiles_result = v; - } - Err(e) => { - problems.push(e.to_string()); - profiles_result = json!({"error": e.to_string()}); - } - } - } + let mut news = ImportNews::default(); + let profiles = if lut_paths.is_empty() { Value::Null } else { import_luts(app, &lut_paths, &mut news) }; let preset_paths: Vec<&String> = paths.iter().filter(|p| !p.to_ascii_lowercase().ends_with(".cube")).collect(); - let r = if preset_paths.is_empty() { - Ok(json!({"imported": [], "failed": [], "skipped": 0})) - } else { - app.session.execute("preset.import", &json!({"paths": preset_paths})).map_err(|e| e.to_string()) - }; - if let (false, Ok(v)) = (preset_paths.is_empty(), &r) { - let n = v["imported"].as_array().map_or(0, Vec::len); - let failed = v["failed"].as_array().map_or(0, Vec::len); - // Lightroom local adjustment presets land in the Masking panel's Effect menu: part of the same toast - let effects = v["effects"].as_array().map_or(0, Vec::len); - if effects > 0 { - parts.push(crate::panels::mask_effects::import_toast(v)); - } - let profile_news = !parts.is_empty() || !problems.is_empty(); - let msg = match (n, failed) { - // a zip of LUTs (or only local presets): the other half already says what happened - (0, 0) if profile_news => None, - (0, 0) => Some("No new presets".to_string()), - (n, 0) => Some(crate::i18n::tr_format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }, n = n)), - (n, f) => Some(crate::i18n::tr_format!( - "Imported {n} preset{}, {f} file{} not readable", - if n == 1 { "" } else { "s" }, - if f == 1 { "" } else { "s" }, - f = f, - n = n - )), - }; - if let Some(mut msg) = msg { - // settings with no counterpart here (the other editor's profiles, masks…) - let mut skipped: Vec<&str> = v["imported"] - .as_array() - .into_iter() - .flatten() - .flat_map(|i| i["unmapped"].as_array().into_iter().flatten()) - .filter_map(Value::as_str) - .collect(); - skipped.sort_unstable(); - skipped.dedup(); - if !skipped.is_empty() { - let names: Vec<&str> = skipped.iter().take(3).copied().collect(); - msg += &crate::i18n::tr_format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" }); - } - parts.push(msg); - } - if n > 0 { - app.ui.presets = true; - } - } - let mut msg = parts.join(" · "); - if !problems.is_empty() { - let detail = format!("{}{}", problems.iter().take(2).cloned().collect::>().join("; "), if problems.len() > 2 { "…" } else { "" }); - if msg.is_empty() { - msg = crate::i18n::tr_format!("Import failed: {e}", e = detail); - } else { - msg += &crate::i18n::tr_format!(" · {} failed: {}", problems.len(), detail); - } - app.toast_error(ctx, msg); - } else if !msg.is_empty() { - app.toast(ctx, msg); - } - + let r = import_preset_files(app, &preset_paths, &mut news); + toast_import_news(app, ctx, &news); r.map(|mut v| { if !lut_paths.is_empty() && let Some(o) = v.as_object_mut() { - o.insert("profiles".into(), profiles_result); + o.insert("profiles".into(), profiles); } v }) @@ -2070,37 +2111,33 @@ fn file_export_mask_effects_command(app: &mut LightcraftApp, ctx: &egui::Context /// The `file.importCurvePresets` command. fn file_import_curve_presets_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { - let paths: Vec = match p.get("paths").and_then(Value::as_array) { - Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), - None => { - let req = PickRequest::files( - Some(crate::i18n::tr("Import Point Curve Presets").into()), - crate::i18n::tr("Point Curve Presets"), - crate::pick::CURVE_PRESET_EXTENSIONS, - ); - match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_curve_preset_files.as_mut().map(|f| f())) { - Picked::Now(paths) => paths, - Picked::Later => return Ok(Value::Null), - Picked::Unavailable => return Err("no file dialog on this platform".into()), - } - } + let request = PickRequest::files( + Some(crate::i18n::tr("Import Point Curve Presets").into()), + crate::i18n::tr("Point Curve Presets"), + crate::pick::CURVE_PRESET_EXTENSIONS, + ); + let paths = match paths_or_pick(app, id, p, request, |s| s.pick_curve_preset_files.as_mut().map(|f| f())) { + Ok(paths) => paths, + Err(answer) => return answer, }; - if paths.is_empty() { - return Ok(Value::Null); - } let r = app.session.execute("curve.importPresets", &json!({"paths": paths})).map_err(|e| e.to_string()); if let Ok(v) = &r { - let n = v["imported"].as_array().map_or(0, Vec::len); - let failed = v["failed"].as_array().map_or(0, Vec::len); - let mut msg = crate::i18n::tr_format!("Imported {n} point curve preset{}", if n == 1 { "" } else { "s" }, n = n); - if failed > 0 { - msg += &crate::i18n::tr_format!(", {failed} file{} not readable", if failed == 1 { "" } else { "s" }, failed = failed); - } - app.toast(ctx, msg); + app.toast(ctx, curve_import_message(v)); } r } +/// What an import of point curve presets did. +fn curve_import_message(v: &Value) -> String { + let n = v["imported"].as_array().map_or(0, Vec::len); + let failed = v["failed"].as_array().map_or(0, Vec::len); + let imported = crate::i18n::tr_format!("Imported {n} point curve preset{}", plural(n), n = n); + if failed == 0 { + return imported; + } + imported + &crate::i18n::tr_format!(", {failed} file{} not readable", plural(failed), failed = failed) +} + /// The `file.exportCurvePresets` command. fn file_export_curve_presets_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { let path = match p.get("path").and_then(Value::as_str) { From 63755f8a2e74c90b86011a514b4e8f5d23ad9b0e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 20:19:42 +0000 Subject: [PATCH 27/39] refactor(ui-egui): split the global shortcut dispatcher --- crates/ui-egui/src/shortcuts.rs | 607 ++++++++++++++++++-------------- 1 file changed, 340 insertions(+), 267 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index c36bcec98..2f0b07d45 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -386,10 +386,22 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { if keyboard_owned(app, ctx) || app.recording_shortcut.is_some() || app.ui.name_edit.is_some() { return; } + let (fire, aliased) = pressed_commands(app, ctx); + for (id, params) in aliased { + fire_alias(app, ctx, id, params); + } + let rotate_fires = fire.iter().any(|f| f == "crop.rotateAspect"); + for f in fire { + fire_command(app, ctx, &f, rotate_fires); + } +} + +/// The keys pressed this frame: the commands to fire, and the aliases (command and params) to run. +fn pressed_commands(app: &LightcraftApp, ctx: &egui::Context) -> (Vec, Vec<(&'static str, serde_json::Value)>) { let mut fire: Vec = Vec::new(); // shortcuts the native menu bar handles (it consumes those key presses itself) let native = |sc: &str| app.native_shortcuts.contains(sc); - let mut aliased: Vec<(&str, serde_json::Value)> = Vec::new(); + let mut aliased: Vec<(&'static str, serde_json::Value)> = Vec::new(); let keymap = &app.ui.settings.keymap; let grid = library_grid(app); // The grid navigation keys act on the photos in view, so they wait while the full-screen preview @@ -433,281 +445,342 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { aliased.push((id, serde_json::from_str(params).unwrap_or_default())); } } - // rating 0-5, colour labels 6-9 (with Shift: and advance) - for (n, key) in [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5].iter().enumerate() { - if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) && !taken.contains(&(Modifiers::NONE, *key)) { - fire.push(format!("rate:{n}:0")); - } - if matches(i, Modifiers::SHIFT, *key) && !taken.contains(&(Modifiers::SHIFT, *key)) { - fire.push(format!("rate:{n}:1")); - } - } - for (label, key, sc) in [("red", Key::Num6, "6"), ("yellow", Key::Num7, "7"), ("green", Key::Num8, "8"), ("blue", Key::Num9, "9")] { - if matches(i, Modifiers::NONE, key) && !native(sc) && !taken.contains(&(Modifiers::NONE, key)) { - fire.push(format!("label:{label}")); - } - } - if grid_navigation { - // Home / End: the first / last photo of the view - for (key, id) in [(Key::Home, "grid:first"), (Key::End, "grid:last")] { - if matches(i, Modifiers::NONE, key) && !taken.contains(&(Modifiers::NONE, key)) { - fire.push(id.to_string()); - } - } - // Shift+← / Shift+→: the previous / next photo of the view joins the selection - for (key, step) in [(Key::ArrowLeft, -1i64), (Key::ArrowRight, 1)] { - if matches(i, Modifiers::SHIFT, key) && !taken.contains(&(Modifiers::SHIFT, key)) { - fire.push(format!("grid:extend:{step}")); - } - } - } + fixed_photo_keys(i, &native, &taken, grid_navigation, &mut fire); }); - use crate::panels::compare; - // rating/flag/label keys: in Compare/Survey they act on the active photo only; Shift+key or - // Auto Advance then moves on (next candidate in Compare, next photo elsewhere) - let cull = |app: &mut LightcraftApp, id: &str, mut params: serde_json::Value, advance: bool| { - compare::target_active(app, &mut params); - let was = app.session.active(); - let ok = app.run(id, params).is_ok(); - if ok && (advance || app.ui.auto_advance) { - compare::advance(app, was); + (fire, aliased) +} + +/// The fixed photo keys pressed: ratings 0-5, colour labels 6-9 (with Shift: and advance), and in a grid the +/// navigation keys. Keys the user gave to a command (`taken`) and the native menu's (`native`) are left alone. +fn fixed_photo_keys(i: &egui::InputState, native: &dyn Fn(&str) -> bool, taken: &[(Modifiers, Key)], grid_navigation: bool, fire: &mut Vec) { + // rating 0-5, colour labels 6-9 (with Shift: and advance) + for (n, key) in [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5].iter().enumerate() { + if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) && !taken.contains(&(Modifiers::NONE, *key)) { + fire.push(format!("rate:{n}:0")); } - }; - for (id, params) in aliased { - // a slideshow: Space pauses / resumes - if id == "view.zoomToggle" && app.ui.slideshow.is_some() { - if let Ok(v) = app.run("slideshow.togglePause", json!({})) { - app.toast(ctx, crate::i18n::tr(if v["paused"] == true { "Slideshow paused" } else { "Slideshow resumed" })); - } - continue; - } - // flag/rate aliases (Shift+X…) go through the culling path: active photo in Compare/Survey, - // `advance` moves to the next candidate there - if matches!(id, "photo.flag" | "photo.rate" | "photo.label") { - let mut params = params; - let advance = params.get("advance").and_then(serde_json::Value::as_bool).unwrap_or(false); - if let Some(o) = params.as_object_mut() { - o.remove("advance"); - } - cull(app, id, params, advance); - } else if let Err(e) = app.run(id, params) - && matches!(id, "app.export" | "app.exportPrevious") - { - // an export that can't start (e.g. no folder) says why instead of doing nothing - app.toast(ctx, e); + if matches(i, Modifiers::SHIFT, *key) && !taken.contains(&(Modifiers::SHIFT, *key)) { + fire.push(format!("rate:{n}:1")); } } - let rotate_fires = fire.iter().any(|f| f == "crop.rotateAspect"); - for f in fire { - // a slideshow: ←/→ step (a cut) - if app.ui.slideshow.is_some() && matches!(f.as_str(), "library.next" | "library.previous") { - let _ = app.run(if f == "library.next" { "slideshow.next" } else { "slideshow.previous" }, json!({})); - continue; + for (label, key, sc) in [("red", Key::Num6, "6"), ("yellow", Key::Num7, "7"), ("green", Key::Num8, "8"), ("blue", Key::Num9, "9")] { + if matches(i, Modifiers::NONE, key) && !native(sc) && !taken.contains(&(Modifiers::NONE, key)) { + fire.push(format!("label:{label}")); } - if let Some(rest) = f.strip_prefix("rate:") { - let (n, adv) = rest.split_once(':').unwrap_or(("0", "0")); - cull(app, "photo.rate", json!({"rating": n.parse::().unwrap_or(0)}), adv == "1"); - let label = if n == "0" { - crate::i18n::tr("Rating cleared").to_string() - } else { - crate::i18n::tr_format!("Rated {}", "★".repeat(n.parse().unwrap_or(0))) - }; - app.toast(ctx, label); - } else if matches!(f.as_str(), "photo.decreaseRating" | "photo.increaseRating") { - cull(app, &f, json!({}), false); - } else if let Some(l) = f.strip_prefix("label:") { - cull(app, "photo.label", json!({"label": l}), false); - } else if f == "panel.presets" && library_grid(app) { - // Classic's Shift+P picks and advances in Library; keep the Presets binding elsewhere. - cull(app, "photo.flag", json!({"flag": "pick"}), true); - } else if f == "view.softProof" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) { - // S in a grid: expand / collapse the stack (Lightroom's Library binding) - let _ = app.run("stack.toggle", json!({})); - } else if f == "grid:first" || f == "grid:last" { - // Home / End in a grid: make the first / last photo of the view the active, selected one - let vis = app.session.visible_cloned(); - let edge = if f == "grid:first" { vis.first() } else { vis.last() }; - if let Some(id) = edge { - let _ = app.run("library.select", json!({"ids": [id.0], "active": id.0})); - } - } else if let Some(step) = f.strip_prefix("grid:extend:") { - // Shift+← / Shift+→ in a grid: the neighbour of the active photo joins the selection and - // becomes the active one. At either end of the view the key does nothing. - let step = step.parse::().unwrap_or(0); - let vis = app.session.visible_cloned(); - let neighbour = app - .session - .active() - .and_then(|active| vis.iter().position(|p| *p == active)) - .and_then(|pos| i64::try_from(pos).ok()) - .and_then(|pos| pos.checked_add(step)) - .and_then(|pos| usize::try_from(pos).ok()) - .and_then(|pos| vis.get(pos)); - if let Some(id) = neighbour { - let _ = app.run("library.select", json!({"ids": [id.0], "mode": "add"})); - } - } else if compare::culling(app) && (f == "library.next" || f == "library.previous") { - let d = if f == "library.next" { 1 } else { -1 }; - let _ = if app.ui.view == crate::state::ViewMode::Compare { compare::compare_step(app, d) } else { compare::survey_step(app, d) }; - } else if matches!(f.as_str(), "photo.cycleFlag" | "photo.increaseFlag" | "photo.decreaseFlag") { - // `, ⌘↑, ⌘↓: step the flag; the toast names the active photo's new state (read before Auto Advance moves on) - let active = app.session.active(); - cull(app, &f, json!({}), false); - let flag = active.and_then(|id| app.session.catalog.photo(id)).map(|p| p.flag); - match flag { - Some(lightcraft_catalog::Flag::Pick) => app.toast(ctx, crate::i18n::tr("Flagged as Pick")), - Some(lightcraft_catalog::Flag::Reject) => app.toast(ctx, crate::i18n::tr("Flagged as Reject")), - Some(lightcraft_catalog::Flag::None) => app.toast(ctx, crate::i18n::tr("Unflagged")), - None => {} - } - } else if matches!(f.as_str(), "photo.pick" | "photo.reject" | "photo.unflag") && app.ui.right != crate::state::RightPanel::Crop { - cull(app, &f, json!({}), false); - match f.as_str() { - "photo.pick" => app.toast(ctx, crate::i18n::tr("Flagged as Pick")), - "photo.reject" => app.toast(ctx, crate::i18n::tr("Flagged as Reject")), - _ => app.toast(ctx, crate::i18n::tr("Unflagged")), - } + } + if !grid_navigation { + return; + } + // Home / End: the first / last photo of the view + for (key, id) in [(Key::Home, "grid:first"), (Key::End, "grid:last")] { + if matches(i, Modifiers::NONE, key) && !taken.contains(&(Modifiers::NONE, key)) { + fire.push(id.to_string()); + } + } + // Shift+← / Shift+→: the previous / next photo of the view joins the selection + for (key, step) in [(Key::ArrowLeft, -1i64), (Key::ArrowRight, 1)] { + if matches(i, Modifiers::SHIFT, key) && !taken.contains(&(Modifiers::SHIFT, key)) { + fire.push(format!("grid:extend:{step}")); + } + } +} + +/// Rating/flag/label keys: in Compare/Survey they act on the active photo only; `advance` (Shift+key) or +/// Auto Advance then moves on (next candidate in Compare, next photo elsewhere). +fn cull(app: &mut LightcraftApp, id: &str, mut params: serde_json::Value, advance: bool) { + use crate::panels::compare; + compare::target_active(app, &mut params); + let was = app.session.active(); + let ok = app.run(id, params).is_ok(); + if ok && (advance || app.ui.auto_advance) { + compare::advance(app, was); + } +} + +/// Run a fixed alias (a second key for a command). +fn fire_alias(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, params: serde_json::Value) { + // a slideshow: Space pauses / resumes + if id == "view.zoomToggle" && app.ui.slideshow.is_some() { + if let Ok(v) = app.run("slideshow.togglePause", json!({})) { + app.toast(ctx, crate::i18n::tr(if v["paused"] == true { "Slideshow paused" } else { "Slideshow resumed" })); + } + return; + } + // flag/rate aliases (Shift+X…) go through the culling path: active photo in Compare/Survey, + // `advance` moves to the next candidate there + if matches!(id, "photo.flag" | "photo.rate" | "photo.label") { + let mut params = params; + let advance = params.get("advance").and_then(serde_json::Value::as_bool).unwrap_or(false); + if let Some(o) = params.as_object_mut() { + o.remove("advance"); + } + cull(app, id, params, advance); + } else if let Err(e) = app.run(id, params) + && matches!(id, "app.export" | "app.exportPrevious") + { + // an export that can't start (e.g. no folder) says why instead of doing nothing + app.toast(ctx, e); + } +} + +/// The toast naming a flag. +fn flag_toast(app: &mut LightcraftApp, ctx: &egui::Context, flag: lightcraft_catalog::Flag) { + use lightcraft_catalog::Flag; + app.toast( + ctx, + crate::i18n::tr(match flag { + Flag::Pick => "Flagged as Pick", + Flag::Reject => "Flagged as Reject", + Flag::None => "Unflagged", + }), + ); +} + +/// Run one fired command. +fn fire_command(app: &mut LightcraftApp, ctx: &egui::Context, f: &str, rotate_fires: bool) { + // a slideshow: ←/→ step (a cut) + if app.ui.slideshow.is_some() && matches!(f, "library.next" | "library.previous") { + let _ = app.run(if f == "library.next" { "slideshow.next" } else { "slideshow.previous" }, json!({})); + return; + } + if fire_rating_key(app, ctx, f) || fire_grid_key(app, f) || fire_flag_key(app, ctx, f) { + return; + } + // in the full-screen preview (no panels) I cycles the info overlay instead + if f == "panel.info" && app.ui.fullscreen { + let _ = app.run("view.infoOverlay", json!({})); + return; + } + if (f == "photo.delete" && delete_key(app, ctx)) || (f == "tool.brush" && brush_key(app, ctx)) || context_key_skipped(app, f, rotate_fires) { + return; + } + if matches!(f, "library.up" | "library.down") { + up_down_key(app, ctx, f); + return; + } + // an export that can't start (e.g. no folder) says why instead of doing nothing + if let Err(e) = app.run(f, json!({})) + && matches!(f, "app.export" | "app.exportPrevious") + { + app.toast(ctx, e); + } + match f { + "photo.pick" => flag_toast(app, ctx, lightcraft_catalog::Flag::Pick), + "photo.reject" => flag_toast(app, ctx, lightcraft_catalog::Flag::Reject), + "photo.unflag" => flag_toast(app, ctx, lightcraft_catalog::Flag::None), + "edit.undo" => app.toast(ctx, crate::i18n::tr("Undo")), + "edit.redo" => app.toast(ctx, crate::i18n::tr("Redo")), + _ => {} + } +} + +/// Rating and colour label keys; whether `f` was one. +fn fire_rating_key(app: &mut LightcraftApp, ctx: &egui::Context, f: &str) -> bool { + if let Some(rest) = f.strip_prefix("rate:") { + let (n, adv) = rest.split_once(':').unwrap_or(("0", "0")); + cull(app, "photo.rate", json!({"rating": n.parse::().unwrap_or(0)}), adv == "1"); + let label = if n == "0" { + crate::i18n::tr("Rating cleared").to_string() } else { - // in the full-screen preview (no panels) I cycles the info overlay instead - if f == "panel.info" && app.ui.fullscreen { - let _ = app.run("view.infoOverlay", json!({})); - continue; + crate::i18n::tr_format!("Rated {}", "★".repeat(n.parse().unwrap_or(0))) + }; + app.toast(ctx, label); + } else if matches!(f, "photo.decreaseRating" | "photo.increaseRating") { + cull(app, f, json!({}), false); + } else if let Some(l) = f.strip_prefix("label:") { + cull(app, "photo.label", json!({"label": l}), false); + } else { + return false; + } + true +} + +/// The keys of a grid or of Compare / Survey that move the active photo or the selection; whether `f` was one. +fn fire_grid_key(app: &mut LightcraftApp, f: &str) -> bool { + use crate::panels::compare; + if f == "panel.presets" && library_grid(app) { + // Classic's Shift+P picks and advances in Library; keep the Presets binding elsewhere. + cull(app, "photo.flag", json!({"flag": "pick"}), true); + } else if f == "view.softProof" && library_grid(app) { + // S in a grid: expand / collapse the stack (Lightroom's Library binding) + let _ = app.run("stack.toggle", json!({})); + } else if f == "grid:first" || f == "grid:last" { + // Home / End in a grid: make the first / last photo of the view the active, selected one + let vis = app.session.visible_cloned(); + let edge = if f == "grid:first" { vis.first() } else { vis.last() }; + if let Some(id) = edge { + let _ = app.run("library.select", json!({"ids": [id.0], "active": id.0})); + } + } else if let Some(step) = f.strip_prefix("grid:extend:") { + extend_selection(app, step.parse::().unwrap_or(0)); + } else if compare::culling(app) && (f == "library.next" || f == "library.previous") { + let d = if f == "library.next" { 1 } else { -1 }; + let _ = if app.ui.view == crate::state::ViewMode::Compare { compare::compare_step(app, d) } else { compare::survey_step(app, d) }; + } else { + return false; + } + true +} + +/// Shift+← / Shift+→ in a grid: the neighbour of the active photo joins the selection and +/// becomes the active one. At either end of the view the key does nothing. +fn extend_selection(app: &mut LightcraftApp, step: i64) { + let vis = app.session.visible_cloned(); + let neighbour = app + .session + .active() + .and_then(|active| vis.iter().position(|p| *p == active)) + .and_then(|pos| i64::try_from(pos).ok()) + .and_then(|pos| pos.checked_add(step)) + .and_then(|pos| usize::try_from(pos).ok()) + .and_then(|pos| vis.get(pos)); + if let Some(id) = neighbour { + let _ = app.run("library.select", json!({"ids": [id.0], "mode": "add"})); + } +} + +/// The flag keys; whether `f` was one. +fn fire_flag_key(app: &mut LightcraftApp, ctx: &egui::Context, f: &str) -> bool { + if matches!(f, "photo.cycleFlag" | "photo.increaseFlag" | "photo.decreaseFlag") { + // `, ⌘↑, ⌘↓: step the flag; the toast names the active photo's new state (read before Auto Advance moves on) + let active = app.session.active(); + cull(app, f, json!({}), false); + if let Some(flag) = active.and_then(|id| app.session.catalog.photo(id)).map(|p| p.flag) { + flag_toast(app, ctx, flag); + } + true + } else if matches!(f, "photo.pick" | "photo.reject" | "photo.unflag") && app.ui.right != crate::state::RightPanel::Crop { + cull(app, f, json!({}), false); + let flag = match f { + "photo.pick" => lightcraft_catalog::Flag::Pick, + "photo.reject" => lightcraft_catalog::Flag::Reject, + _ => lightcraft_catalog::Flag::None, + }; + flag_toast(app, ctx, flag); + true + } else { + false + } +} + +/// Delete acts on what's being edited: the active mask in the Masking panel; never the photo while retouching (spots +/// are removed from their own panel). Whether the key was used up. +fn delete_key(app: &mut LightcraftApp, ctx: &egui::Context) -> bool { + use crate::state::RightPanel as R; + match app.ui.right { + R::Masking => { + if app.session.active_mask.is_some() { + let _ = app.run("mask.delete", json!({})); } - // Delete acts on what's being edited: the active mask in the Masking panel; never the - // photo while retouching (spots are removed from their own panel). - if f == "photo.delete" { - use crate::state::RightPanel as R; - match app.ui.right { - R::Masking => { - if app.session.active_mask.is_some() { - let _ = app.run("mask.delete", json!({})); - } - continue; - } - R::Remove => { - if app.session.active_spot.is_some() { - let _ = app.run("spot.delete", json!({})); - } - continue; - } - R::Crop if app.ui.tool == "guidedUpright" => { - // Delete removes the selected guide alone; with none selected it removes - // nothing (the photo itself is never deleted while the tool is open) - if let (Some((photo, i)), Some(active)) = (app.ui.selected_guide, app.session.active()) { - if photo == active.0 - && let Some(d) = app.session.develop_of(active) - && i < d.geometry.guides.len() - { - let guides: Vec<[f64; 4]> = - d.geometry.guides.iter().enumerate().filter(|(k, _)| *k != i).map(|(_, (a, b))| [a.x, a.y, b.x, b.y]).collect(); - let _ = app.run("geometry.guides", json!({"guides": guides})); - } - app.ui.selected_guide = None; - } - continue; - } - R::RedEye => continue, - _ => {} - } - // in an album, Delete takes the photos out of the album only, as in Lightroom: they - // stay in the library and their folder (issue #1805) - if crate::menus::delete_in_album(app, ctx) { - continue; - } - if crate::menus::confirm_delete(app) { - continue; - } + return true; + } + R::Remove => { + if app.session.active_spot.is_some() { + let _ = app.run("spot.delete", json!({})); } - // B: the brush while editing; in the grids, add to the target album (Quick Collection) - if f == "tool.brush" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) { - if let Ok(r) = app.run("album.toggleTarget", json!({})) { - let n = app.session.selected().len(); - let what = crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n); - let name = r["name"].as_str().unwrap_or("Quick Collection").to_string(); - app.toast( - ctx, - if r["added"] == true { - crate::i18n::tr_format!("Added {what} to {name}", name = name, what = what) - } else { - crate::i18n::tr_format!("Removed {what} from {name}", name = name, what = what) - }, - ); - } - continue; - } - // X is both reject (library) and swap crop aspect (crop tool). When both bindings fire, - // swap once: twice would put the crop straight back (issue #1866). - if f == "photo.reject" && app.ui.right == crate::state::RightPanel::Crop { - if !rotate_fires { - let _ = app.run("crop.rotateAspect", json!({})); - } - continue; - } - if f == "crop.rotateAspect" && app.ui.right != crate::state::RightPanel::Crop { - continue; - } - // A is both Visualize Spots and the crop aspect lock: each key only in its own context - if f == "view.visualizeSpots" && app.ui.right == crate::state::RightPanel::Crop { - continue; - } - if f == "crop.lockAspect" && app.ui.right != crate::state::RightPanel::Crop { - continue; - } - // / refreshes the selected spot's source in the Remove tool (the filmstrip elsewhere) - if f == "view.filmstrip" && app.ui.right == crate::state::RightPanel::Remove && app.session.active_spot.is_some() { - let _ = app.run("spot.refreshSource", json!({})); - continue; - } - // Shift+O cycles the mask overlay colour while masking (the crop overlay elsewhere) - if f == "view.cropOverlay" && app.ui.right == crate::state::RightPanel::Masking { - let _ = app.run("view.maskOverlayColor", json!({})); - continue; - } - // while cropping: O cycles the guides, Shift+O their orientation - if app.ui.right == crate::state::RightPanel::Crop { - let crop_key = match f.as_str() { - "view.maskOverlay" => Some(("view.cropOverlay", json!({}))), - "view.cropOverlay" => Some(("view.cropOverlayOrientation", json!({}))), - _ => None, - }; - if let Some((cmd, p)) = crop_key { - let _ = app.run(cmd, p); - continue; - } - } - // Up / Down in the grids go to the cell above / below (the filmstrip steps one photo) - if matches!(f.as_str(), "library.up" | "library.down") { - // over a slider the arrows nudge it (widgets::slider takes the key) - let over_slider = - ctx.pointer_hover_pos().is_some_and(|p| app.widgets.iter().any(|(id, r)| id.starts_with("slider:") && r.contains(p))); - if over_slider { - continue; - } - match crate::panels::grid::row_distance(app, f == "library.down") { - Some(0) => {} - Some(rows) => { - let _ = app.run(&f, json!({"rows": rows})); - } - None => { - let _ = app.run(&f, json!({})); - } - } - continue; - } - // an export that can't start (e.g. no folder) says why instead of doing nothing - if let Err(e) = app.run(&f, json!({})) - && matches!(f.as_str(), "app.export" | "app.exportPrevious") - { - app.toast(ctx, e); - } - match f.as_str() { - "photo.pick" => app.toast(ctx, crate::i18n::tr("Flagged as Pick")), - "photo.reject" => app.toast(ctx, crate::i18n::tr("Flagged as Reject")), - "photo.unflag" => app.toast(ctx, crate::i18n::tr("Unflagged")), - "edit.undo" => app.toast(ctx, crate::i18n::tr("Undo")), - "edit.redo" => app.toast(ctx, crate::i18n::tr("Redo")), - _ => {} + return true; + } + R::Crop if app.ui.tool == "guidedUpright" => { + delete_selected_guide(app); + return true; + } + R::RedEye => return true, + _ => {} + } + // in an album, Delete takes the photos out of the album only, as in Lightroom: they + // stay in the library and their folder (issue #1805) + crate::menus::delete_in_album(app, ctx) || crate::menus::confirm_delete(app) +} + +/// Delete removes the selected guide alone; with none selected it removes nothing (the photo itself is never deleted +/// while the tool is open). +fn delete_selected_guide(app: &mut LightcraftApp) { + let (Some((photo, i)), Some(active)) = (app.ui.selected_guide, app.session.active()) else { return }; + if photo == active.0 + && let Some(d) = app.session.develop_of(active) + && i < d.geometry.guides.len() + { + let guides: Vec<[f64; 4]> = d.geometry.guides.iter().enumerate().filter(|(k, _)| *k != i).map(|(_, (a, b))| [a.x, a.y, b.x, b.y]).collect(); + let _ = app.run("geometry.guides", json!({"guides": guides})); + } + app.ui.selected_guide = None; +} + +/// B: the brush while editing; in the grids, add to the target album (Quick Collection). Whether the key was used up. +fn brush_key(app: &mut LightcraftApp, ctx: &egui::Context) -> bool { + if !library_grid(app) { + return false; + } + if let Ok(r) = app.run("album.toggleTarget", json!({})) { + let n = app.session.selected().len(); + let what = crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n); + let name = r["name"].as_str().unwrap_or("Quick Collection").to_string(); + app.toast( + ctx, + if r["added"] == true { + crate::i18n::tr_format!("Added {what} to {name}", name = name, what = what) + } else { + crate::i18n::tr_format!("Removed {what} from {name}", name = name, what = what) + }, + ); + } + true +} + +/// Keys that two bindings share, and that mean something only in one panel: whether `f` was used up (or ignored) +/// here, so nothing else runs for it. +fn context_key_skipped(app: &mut LightcraftApp, f: &str, rotate_fires: bool) -> bool { + use crate::state::RightPanel as R; + let right = app.ui.right; + match f { + // X is both reject (library) and swap crop aspect (crop tool). When both bindings fire, + // swap once: twice would put the crop straight back (issue #1866). + "photo.reject" if right == R::Crop => { + if !rotate_fires { + let _ = app.run("crop.rotateAspect", json!({})); } + true + } + "crop.rotateAspect" => right != R::Crop, + // A is both Visualize Spots and the crop aspect lock: each key only in its own context + "view.visualizeSpots" => right == R::Crop, + "crop.lockAspect" => right != R::Crop, + // / refreshes the selected spot's source in the Remove tool (the filmstrip elsewhere) + "view.filmstrip" if right == R::Remove && app.session.active_spot.is_some() => { + let _ = app.run("spot.refreshSource", json!({})); + true + } + // Shift+O cycles the mask overlay colour while masking (the crop overlay elsewhere) + "view.cropOverlay" if right == R::Masking => { + let _ = app.run("view.maskOverlayColor", json!({})); + true + } + // while cropping: O cycles the guides, Shift+O their orientation + "view.maskOverlay" if right == R::Crop => { + let _ = app.run("view.cropOverlay", json!({})); + true + } + "view.cropOverlay" if right == R::Crop => { + let _ = app.run("view.cropOverlayOrientation", json!({})); + true + } + _ => false, + } +} + +/// Up / Down in the grids go to the cell above / below (the filmstrip steps one photo). +fn up_down_key(app: &mut LightcraftApp, ctx: &egui::Context, f: &str) { + // over a slider the arrows nudge it (widgets::slider takes the key) + let over_slider = ctx.pointer_hover_pos().is_some_and(|p| app.widgets.iter().any(|(id, r)| id.starts_with("slider:") && r.contains(p))); + if over_slider { + return; + } + match crate::panels::grid::row_distance(app, f == "library.down") { + Some(0) => {} + Some(rows) => { + let _ = app.run(f, json!({"rows": rows})); + } + None => { + let _ = app.run(f, json!({})); } } } From 627804c161ae0b001ac88f01194e6b6b7253d25c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 20:20:44 +0000 Subject: [PATCH 28/39] refactor(ui-egui): split import finish and print settings --- crates/ui-egui/src/dialogs/import/task.rs | 74 +++++++++++++---------- crates/ui-egui/src/dialogs/print_view.rs | 46 +++++++++----- 2 files changed, 73 insertions(+), 47 deletions(-) diff --git a/crates/ui-egui/src/dialogs/import/task.rs b/crates/ui-egui/src/dialogs/import/task.rs index c41a941a3..da2c1574f 100644 --- a/crates/ui-egui/src/dialogs/import/task.rs +++ b/crates/ui-egui/src/dialogs/import/task.rs @@ -422,33 +422,29 @@ fn xmp_warnings_text(warnings: &[ImportWarning]) -> String { ) } -/// The import is done (or cancelled): one undo step, show and select the first photo (unless the -/// user chose another meanwhile), say what happened. -fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { - let steps = app.session.undo.len().saturating_sub(task.undo0); - let plural = |n: usize| if n == 1 { "" } else { "s" }; - let label = if task.imported == 0 && task.restored > 0 { +/// `s` for a count other than one. +fn plural(n: usize) -> &'static str { + if n == 1 { "" } else { "s" } +} + +/// The name of the import's one undo step. +fn undo_label(task: &ImportTask) -> String { + if task.imported == 0 && task.restored > 0 { crate::i18n::tr_format!("Restore {} Photo{}", task.restored, plural(task.restored)) } else if task.restored > 0 { crate::i18n::tr_format!("Add {} Photo{}, restore {}", task.imported, plural(task.imported), task.restored) } else { - crate::i18n::tr_format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" }) - }; - app.session.merge_undo(steps, &label); - if task.auto { - if !task.warnings.is_empty() { - app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0); - } else if task.imported > 0 { - app.toast(ctx, crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" })); - } - return; + crate::i18n::tr_format!("Add {} Photo{}", task.imported, plural(task.imported)) } - if task.browse { - if !task.warnings.is_empty() { - app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0); - } else if task.failed > 0 { - app.toast(ctx, crate::i18n::tr_format!("{} photo{} not readable", task.failed, if task.failed == 1 { "" } else { "s" })); - } +} + +/// The import is done (or cancelled): one undo step, show and select the first photo (unless the +/// user chose another meanwhile), say what happened. +fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { + let steps = app.session.undo.len().saturating_sub(task.undo0); + app.session.merge_undo(steps, &undo_label(&task)); + if task.auto || task.browse { + quiet_finish(app, ctx, &task); return; } if let Some(f) = task.first.filter(|_| !task.moved_on) { @@ -459,7 +455,6 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { } let _ = app.run("library.select", json!({"ids": [f]})); } - let plural = |n: usize| if n == 1 { "" } else { "s" }; // nothing new, only files the library already has: show where they are (Recently Deleted is // easy to miss, and the side panel that lists it starts collapsed) if task.imported == 0 @@ -471,6 +466,29 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { { return; } + let mut msg = import_summary(app, &task); + if task.warnings.is_empty() { + app.toast(ctx, msg); + } else { + msg.push_str("\n\n"); + msg.push_str(&xmp_warnings_text(&task.warnings)); + app.toast_for(ctx, msg, 8.0); + } +} + +/// The end of an automatic import or of one that only browses: a toast only when there is something to warn about. +fn quiet_finish(app: &mut LightcraftApp, ctx: &egui::Context, task: &ImportTask) { + if !task.warnings.is_empty() { + app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0); + } else if task.auto && task.imported > 0 { + app.toast(ctx, crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, plural(task.imported))); + } else if !task.auto && task.failed > 0 { + app.toast(ctx, crate::i18n::tr_format!("{} photo{} not readable", task.failed, plural(task.failed))); + } +} + +/// What an import did, in a sentence. +fn import_summary(app: &LightcraftApp, task: &ImportTask) -> String { let mut msg = if task.cancelled { crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, plural(task.imported)) } else if task.imported == 0 && task.restored > 0 { @@ -488,7 +506,7 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { } } if task.duplicates > 0 { - msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" })); + msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, plural(task.duplicates))); let deleted = deleted_of(app, &task.existing).len(); if deleted > 0 { msg.push_str(&crate::i18n::tr_format!(" ({} in Recently Deleted)", deleted)); @@ -497,13 +515,7 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { if task.failed > 0 { msg.push_str(&crate::i18n::tr_format!(" · {} not readable", task.failed)); } - if task.warnings.is_empty() { - app.toast(ctx, msg); - } else { - msg.push_str("\n\n"); - msg.push_str(&xmp_warnings_text(&task.warnings)); - app.toast_for(ctx, msg, 8.0); - } + msg } /// The photos among `ids` that are in Recently Deleted (each once). diff --git a/crates/ui-egui/src/dialogs/print_view.rs b/crates/ui-egui/src/dialogs/print_view.rs index 82a2b564d..c9af1d776 100644 --- a/crates/ui-egui/src/dialogs/print_view.rs +++ b/crates/ui-egui/src/dialogs/print_view.rs @@ -196,13 +196,9 @@ pub fn body(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens) { app.ui.print = pu; } -fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, st: &PrintSettings, photos: usize) { +/// The print templates: save the settings as one, import, and the saved ones to apply, export and delete. +fn templates_section(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi) { let ctx = ui.ctx().clone(); - let l = &st.layout; - let unit = st.guides.units; - ui.spacing_mut().item_spacing.y = 5.0; - ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if photos == 1 { "" } else { "s" }, n = photos)).color(t.text_dim)); - heading(ui, t, "Templates"); ui.horizontal(|ui| { crate::text_field::TextField::singleline("field:printTemplateName", &mut pu.template_name) @@ -246,17 +242,13 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, }); }); } +} - heading(ui, t, "Layout Style"); - let styles = - [(Style::SingleImage, "Single"), (Style::ContactSheet, "Contact"), (Style::PicturePackage, "Package"), (Style::CustomPackage, "Custom")]; - ui.horizontal_wrapped(|ui| { - if let Some(s) = choice(ui, "printStyle", &styles, l.style) { - patch(app, &ctx, json!({"layout": {"style": s}})); - pu.page = 0; - } - }); - +/// The image settings: zoom, rotation, border and stroke. +fn image_section(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, st: &PrintSettings) { + let ctx = ui.ctx().clone(); + let l = &st.layout; + let unit = st.guides.units; heading(ui, t, "Image Settings"); let im = &st.image; if let Some(v) = check(ui, "printZoomFill", "Zoom to Fill Frame", im.zoom_to_fill) { @@ -292,6 +284,28 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, patch(app, &ctx, json!({"image": {"strokeColor": c}})); } }); +} + +fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, st: &PrintSettings, photos: usize) { + let ctx = ui.ctx().clone(); + let l = &st.layout; + let unit = st.guides.units; + ui.spacing_mut().item_spacing.y = 5.0; + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if photos == 1 { "" } else { "s" }, n = photos)).color(t.text_dim)); + + templates_section(app, ui, t, pu); + + heading(ui, t, "Layout Style"); + let styles = + [(Style::SingleImage, "Single"), (Style::ContactSheet, "Contact"), (Style::PicturePackage, "Package"), (Style::CustomPackage, "Custom")]; + ui.horizontal_wrapped(|ui| { + if let Some(s) = choice(ui, "printStyle", &styles, l.style) { + patch(app, &ctx, json!({"layout": {"style": s}})); + pu.page = 0; + } + }); + + image_section(app, ui, t, st); heading(ui, t, "Layout"); match l.style { From 335defee474b9563f3b19ac630e5b7104d2832ec Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 20:25:31 +0000 Subject: [PATCH 29/39] refactor(ui-egui): one function per dialog body --- crates/ui-egui/src/dialogs.rs | 2309 ++++++++++++++++++--------------- 1 file changed, 1266 insertions(+), 1043 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 772303ffb..1e5ae5e2d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -220,1049 +220,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { ui.disable(); } ui.spacing_mut().item_spacing.y = 8.0; - body_h = dialog_body(ui, (!import && !print).then_some(title.as_str()), body_max, |ui| match &mut dlg { - Dialog::AutoStack { gap } => { - ui.label(egui::RichText::new(crate::i18n::tr("Stack photos taken within this time of each other:")).color(t.text_label)); - let r = ui.add( - egui::Slider::new(gap, 0.0..=86400.0) - .logarithmic(true) - .smallest_positive(1.0) - .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) - .custom_parser(crate::panels::dialogs::parse_gap), - ); - crate::widgets::named(r, crate::i18n::tr("Stack photos taken within this time of each other:")); - let preview = app.session.execute("stack.auto", &json!({"gap": *gap, "preview": true})).unwrap_or_default(); - let scope = if app.session.selection.ids.len() > 1 { "the selected photos" } else { "the photos in view" }; - ui.label( - egui::RichText::new(crate::i18n::tr_format!("Creates {} stacks from {} of {scope}", preview["stacks"], preview["photos"], scope = crate::i18n::tr(scope))).color(t.text_dim), - ); - } - Dialog::Cull { reject_below, pick_best, burst_gap, similarity } => { - let n = app.session.selection.ids.len(); - let scope = if n > 1 { crate::i18n::tr_format!("the {n} selected photos", n = n) } else { crate::i18n::tr_format!("the {} photos in view", app.session.visible_cloned().len()) }; - ui.label(egui::RichText::new(crate::i18n::tr_format!("Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).", scope = scope)).color(t.text_label)); - ui.add_space(6.0); - let r = ui.add(egui::Slider::new(reject_below, 0.0..=80.0).text(crate::i18n::tr("Reject below focus")).step_by(1.0)); - crate::widgets::register(ui, "field:cullReject", r.rect); - ui.label(egui::RichText::new(crate::i18n::tr(if *reject_below > 0.0 { "Blurry photos below the score are flagged as rejects." } else { "0: nothing is rejected." })).color(t.text_dim)); - let r = ui.checkbox(pick_best, crate::i18n::tr("Pick the sharpest photo of each burst")); - crate::widgets::register(ui, "check:cullPick", r.rect); - ui.add_space(6.0); - // the sliders drag over the usual range; their value fields take whatever - // `photo.analyze` accepts (a gap of 0 s to a day, a similarity of 0 to 1) - let r = ui.add( - egui::Slider::new(burst_gap, 1.0..=600.0) - .logarithmic(true) - .smallest_positive(1.0) - .clamping(egui::SliderClamping::Never) - .text(crate::i18n::tr("Burst: shots at most this far apart")) - .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) - .custom_parser(crate::panels::dialogs::parse_gap), - ); - crate::widgets::register(ui, "field:cullGap", r.rect); - let r = ui.add( - egui::Slider::new(similarity, 0.70..=0.99) - .clamping(egui::SliderClamping::Never) - .text(crate::i18n::tr("Burst: how alike (loose … strict)")) - // a typed 0.995 is kept (whole hundredths would round it to 1.00) - .step_by(0.001) - .min_decimals(2) - .max_decimals(3), - ); - crate::widgets::register(ui, "field:cullSimilar", r.rect); - clamp_cull_burst(burst_gap, similarity); - ui.label(egui::RichText::new(crate::i18n::tr("Lower it when a panning or moving burst splits into several groups.")).color(t.text_dim)); - ui.label(egui::RichText::new(crate::i18n::tr("Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.")).color(t.text_dim)); - } - Dialog::WhatsNew => { - egui::ScrollArea::vertical().max_height(list_height(ui, 460.0)).auto_shrink([false, true]).show(ui, |ui| { - for line in WHATS_NEW.lines() { - let l = line.trim_end(); - if let Some(h) = l.strip_prefix("### ") { - ui.add_space(6.0); - ui.label(egui::RichText::new(crate::i18n::tr(h)).font(t.semibold(12.5)).color(t.text)); - } else if let Some(h) = l.strip_prefix("## ") { - ui.add_space(8.0); - ui.label(egui::RichText::new(crate::i18n::tr(h)).font(t.semibold(14.0)).color(t.text)); - } else if l.starts_with("# ") || l.is_empty() { - } else if let Some(b) = l.strip_prefix("- ") { - ui.label(egui::RichText::new(format!("• {}", crate::i18n::tr(b).replace('`', ""))).color(t.text_label)); - } else { - ui.label(egui::RichText::new(crate::i18n::tr(l.trim()).replace('`', "")).color(t.text_label)); - } - } - }); - } - Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::model_dialog(app, ui, &t, info, accepted), - Dialog::FaceModel { info, accepted, .. } => crate::panels::faces::model_dialog(app, ui, &t, info, accepted), - Dialog::LightroomResult { summary, library, details, .. } => { - ui.label(summary.as_str()); - ui.add_space(8.0); - if let Some(library) = library { - ui.label(crate::i18n::tr("Destination library")); - ui.add(egui::Label::new(library.as_str()).wrap().selectable(true)); - ui.label(crate::i18n::tr("Lightroom catalogs import into the open library; no separate catalog folder is created.")); - } else { - ui.label(crate::i18n::tr("These photos are only in memory. Open a library on disk and import the catalog again to save them.")); - } - for detail in details.iter() { - ui.add(egui::Label::new(detail).wrap().selectable(true)); - } - } - Dialog::SystemInfo { rows } => { - egui::Grid::new("sysinfo").num_columns(2).spacing([16.0, 4.0]).striped(true).show(ui, |ui| { - for (k, v) in rows.iter() { - ui.label(egui::RichText::new(crate::i18n::tr(k)).color(t.text_dim)); - ui.add(egui::Label::new(egui::RichText::new(v).color(t.text_label)).wrap()); - ui.end_row(); - } - }); - if ui.button(crate::i18n::tr("Copy to Clipboard")).clicked() { - let text: String = rows.iter().map(|(k, v)| format!("{k}: {v}\n")).collect(); - ui.ctx().copy_text(text); - } - } - Dialog::AllMetadata { title, rows, search } => { - ui.label(egui::RichText::new(title.as_str()).color(t.text_label)); - let r = crate::widgets::named(ui.add(egui::TextEdit::singleline(search).hint_text(crate::i18n::tr("Filter fields")).desired_width(f32::INFINITY)), crate::i18n::tr("Filter fields")); - crate::widgets::register(ui, "field:metadataSearch", r.rect); - let q = search.trim().to_lowercase(); - let keep = |n: &str, v: &str| q.is_empty() || n.to_lowercase().contains(&q) || v.to_lowercase().contains(&q); - let mut groups: Vec<(String, Vec<(String, String)>)> = Vec::new(); - for row in rows["exif"].as_array().into_iter().flatten() { - let (g, n, v) = (row["group"].as_str().unwrap_or(""), row["name"].as_str().unwrap_or(""), row["value"].as_str().unwrap_or("")); - if !keep(n, v) { - continue; - } - match groups.iter_mut().find(|(x, _)| x == g) { - Some((_, list)) => list.push((n.into(), v.into())), - None => groups.push((g.into(), vec![(n.into(), v.into())])), - } - } - let xmp: Vec<(String, String)> = rows["xmp"] - .as_array() - .into_iter() - .flatten() - .map(|r| (r["name"].as_str().unwrap_or("").to_string(), r["value"].as_str().unwrap_or("").to_string())) - .filter(|(n, v)| keep(n, v)) - .collect(); - if !xmp.is_empty() { - groups.push(("XMP".into(), xmp)); - } - egui::ScrollArea::vertical().max_height(list_height(ui, 440.0)).auto_shrink([false, true]).show(ui, |ui| { - if groups.is_empty() { - ui.label(egui::RichText::new(crate::i18n::tr(rows["note"].as_str().unwrap_or("No metadata found"))).color(t.text_dim)); - } - for (g, list) in &groups { - ui.add_space(6.0); - ui.label(egui::RichText::new(g).font(t.semibold(12.5)).color(t.text)); - egui::Grid::new(format!("meta-{g}")).num_columns(2).spacing([16.0, 3.0]).striped(true).show(ui, |ui| { - for (n, v) in list { - ui.label(egui::RichText::new(n).color(t.text_dim)); - ui.add(egui::Label::new(egui::RichText::new(v).color(t.text_label)).wrap()); - ui.end_row(); - } - }); - } - }); - } - Dialog::SmartRules { id, name, rules, .. } => { - let r = crate::widgets::named(ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY)), crate::i18n::tr("Name")); - crate::widgets::register(ui, "field:smartName", r.rect); - ui.add_space(6.0); - // the folder an album made from a folder view carries is not in the editor, but it counts - let folder = id.and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id))).and_then(|a| a.smart.as_deref().and_then(|f| f.library_folder.clone())); - // problems, count and albums: cached until the catalog or the rules change (not per frame) - let now = (app.session.clock)(); - let view = app.caches.rules_view(&app.session.catalog, rules, *id, folder, &now); - let env = crate::panels::rules_editor::Env { problems: view.problems.clone(), today: now, albums: view.albums.clone() }; - egui::ScrollArea::vertical().max_height(list_height(ui, 360.0)).auto_shrink([false, true]).show(ui, |ui| { - crate::panels::rules_editor::edit(ui, rules, "rules", 0, &[], &env); - }); - let n = view.count; - ui.add_space(4.0); - if view.problems.is_empty() { - ui.label( - egui::RichText::new(crate::i18n::tr_format!("{n} photo{} match · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n)) - .color(t.text_dim), - ); - } else { - ui.label(egui::RichText::new(crate::i18n::tr("Fix the marked rules to save this album.")).color(t.caution)); - } - } - Dialog::NewSmartAlbum { name, .. } => { - if dialog_field(ui, "field:smartAlbumName", name, "Name") { - confirm = true; - } - let rules = app.session.view_rules(); - // counted when the library or the rules change (or, for "in the last…" rules, the minute), not every frame - let now = (app.session.clock)(); - let minute = if rules.depends_on_now() { now.get(..16).unwrap_or(&now) } else { "" }; - let n = app.caches.dialog_count(&app.session.catalog, ("smartAlbum", format!("{rules:?}"), minute), || { - lightcraft_catalog::rules::set_now(Some(now.clone())); - app.session.catalog.query(&rules, &Default::default()).len() - }); - ui.label(egui::RichText::new(crate::i18n::tr_format!("Matches: {}", crate::i18n::filter_label(&rules, &app.session.catalog))).color(t.text_label)); - ui.label( - egui::RichText::new(crate::i18n::tr_format!("{n} photo{} now · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n)) - .color(t.text_dim), - ); - } - Dialog::CaptureTime { mode, time, days, hours, minutes, zone } => { - let n = app.session.selected().len(); - field(ui, "Change", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - for (i, (label, m)) in [("Set date & time", "set"), ("Shift by", "shift"), ("Time zone", "zone")].iter().enumerate() { - if crate::widgets::text_button(ui, &format!("captureMode-{i}"), label, mode == m).clicked() { - *mode = m.to_string(); - } - } - }); - let params = match mode.as_str() { - "set" => { - field(ui, "New time", |ui| { - let r = ui.add(egui::TextEdit::singleline(time).hint_text("2026-09-30 14:05:00").desired_width(f32::INFINITY)); - crate::widgets::register(ui, "field:captureTime", r.rect); - }); - json!({"time": time}) - } - "shift" => { - field(ui, "Shift", |ui| { - ui.add(egui::DragValue::new(days).suffix(" d")); - ui.add(egui::DragValue::new(hours).suffix(" h")); - ui.add(egui::DragValue::new(minutes).suffix(" min")); - }); - json!({"shift": *days as i64 * 86_400 + *hours as i64 * 3600 + *minutes as i64 * 60}) - } - _ => { - field(ui, "Hours", |ui| ui.add(egui::DragValue::new(zone).range(-26.0..=26.0).speed(0.25).fixed_decimals(2))); - json!({"hours": zone}) - } - }; - // preview on the active photo (the others move by the same amount) - let active = app.session.active().and_then(|id| app.session.catalog.photo(id)).map(|p| (p.file_name.clone(), p.date().to_string())); - if let Some((name, cur)) = active { - let delta = match mode.as_str() { - "set" => lightcraft_catalog::dates::normalize_iso(time) - .and_then(|t| Some(lightcraft_catalog::dates::iso_seconds(&t)? - lightcraft_catalog::dates::iso_seconds(&cur)?)), - "shift" => params["shift"].as_i64(), - _ => Some((*zone as f64 * 3600.0).round() as i64), - }; - // the photo's own time is the problem (a Lightroom catalog's capture time - // is copied verbatim), not what was typed - let unreadable = lightcraft_catalog::dates::shift_iso(&cur, 0).is_none(); - let after = match (mode.as_str(), unreadable && n == 1) { - // alone, it simply gets the time typed (photo.setCaptureTime repairs it) - ("set", true) => lightcraft_catalog::dates::normalize_iso(time), - _ => delta.and_then(|d| lightcraft_catalog::dates::shift_iso(&cur, d)), - }; - ui.label( - egui::RichText::new(match after { - Some(a) => format!("{name}: {} → {}", cur.replace('T', " "), a.replace('T', " ")), - None if unreadable && !(mode == "set" && n == 1) => crate::i18n::tr("The active photo's capture time can't be read").to_string(), - None => "Enter a date as YYYY-MM-DD HH:MM:SS".into(), - }) - .color(t.text_label), - ); - } - if n > 1 { - ui.label(egui::RichText::new(crate::i18n::tr_format!("All {n} selected photos move by the same amount.", n = n)).color(t.text_dim)); - } - } - Dialog::LabelNames { names, save_as } => { - names.resize(5, String::new()); - // start from a set - let sets = lightcraft_engine::cmd::manage::label_sets_json(&app.session); - field(ui, "Set", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - for set in sets["sets"].as_array().into_iter().flatten() { - let name = set["name"].as_str().unwrap_or_default(); - if crate::widgets::text_button(ui, &format!("labelSet-{name}"), name, false).clicked() { - *names = set["names"].as_array().into_iter().flatten().map(|n| n.as_str().unwrap_or_default().to_string()).collect(); - } - } - }); - for (i, l) in lightcraft_catalog::ColorLabel::ALL.iter().enumerate() { - let colour = format!("{l:?}"); - field(ui, &colour, |ui| { - let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover()); - ui.painter().circle_filled(r.center(), 6.0, crate::panels::grid::label_color(*l)); - let te = crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut names[i]).hint_text(colour.as_str()).desired_width(f32::INFINITY)), crate::i18n::tr(&colour)); - crate::widgets::register(ui, format!("field:labelName-{}", colour.to_lowercase()), te.rect); - }); - } - field(ui, "Save as set", |ui| { - let te = crate::widgets::named(ui.add(egui::TextEdit::singleline(save_as).hint_text(crate::i18n::tr("Optional name")).desired_width(f32::INFINITY)), crate::i18n::tr("Save as set")); - crate::widgets::register(ui, "field:labelSetName", te.rect); - }); - ui.label( - egui::RichText::new(crate::i18n::tr("Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.")) - .color(t.text_dim), - ); - } - Dialog::Rename { template, start } => { - let n = app.session.selected().len(); - ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); - let template_id = egui::Id::unique("rename-template"); - let tags_open = field(ui, "Template", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - let w = crate::import::tag_field_width(ui); - let r = crate::widgets::named(ui.add(egui::TextEdit::singleline(template).id(template_id).hint_text("{name}").desired_width(w)), crate::i18n::tr("Template")); - crate::widgets::register(ui, "field:renameTemplate", r.rect); - crate::import::tag_toggle(ui, "renameTemplate") - }); - if tags_open { - crate::import::tag_help(ui, "renameTemplate", template, template_id); - } - crate::import::unknown_tags_warning(ui, template); - field(ui, "Presets", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - for (i, (label, tpl)) in - [("Name", "{name}"), ("Date–Name", "{date}_{name}"), ("Title–Seq", "{title}-{seq:3}"), ("Custom–Seq", "Photo-{seq:3}")].iter().enumerate() - { - if crate::widgets::text_button(ui, &format!("renamePreset-{i}"), label, template == tpl).clicked() { - *template = tpl.to_string(); - } - } - }); - field(ui, "Start at", |ui| ui.add(egui::DragValue::new(start).range(0..=999_999))); - ui.label( - egui::RichText::new(crate::i18n::tr("Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…")) - .color(t.text_dim), - ); - let (preview, total) = rename_preview(app, ui.ctx(), template, *start as usize); - match &preview { - Some(rows) => { - egui::Grid::new("rename-preview").num_columns(3).spacing([8.0, 2.0]).show(ui, |ui| { - for pl in rows { - ui.label(egui::RichText::new(&pl.from).color(t.text_dim)); - let (arrow, _) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); - crate::icons::paint(ui.painter(), arrow, crate::icons::Icon::ChevronRight, t.text_dim); - ui.label(egui::RichText::new(&pl.to).color(t.text)); - ui.end_row(); - } - }); - } - None => { - ui.horizontal(|ui| { - crate::progress::spinner(ui, 12.0); - ui.label(egui::RichText::new(crate::i18n::tr("Checking names…")).color(t.text_dim)); - }); - } - } - let more = total.saturating_sub(RENAME_PREVIEW_ROWS); - if more > 0 { - ui.label(egui::RichText::new(crate::i18n::tr_format!("… and {more} more", more = more)).color(t.text_dim)); - } - } - Dialog::RenamePerson { from, to, merge } => { - let submitted = dialog_field(ui, "field:personName", to, "Name"); - let plan = person_rename_plan(&app.session.catalog, from, to); - if !plan.needs_merge { - *merge = false; - } - if submitted && plan.block.is_none() && (!plan.needs_merge || *merge) { - confirm = true; - } - if let Some(why) = plan.block { - ui.label(egui::RichText::new(crate::i18n::tr(why)).color(t.caution)); - } else { - ui.label( - egui::RichText::new(crate::i18n::tr_format!( - "Renames {n} face{} across the library. The files are not changed.", - if plan.faces == 1 { "" } else { "s" }, - n = plan.faces - )) - .color(t.text_dim), - ); - } - if plan.needs_merge { - ui.label( - egui::RichText::new(crate::i18n::tr_format!("“{to}” is already a person. Combining them makes one person; Undo separates them again.", to = to.trim())) - .color(t.caution), - ); - let r = ui.checkbox(merge, crate::i18n::tr("Combine with the existing person")); - crate::widgets::register(ui, "check:personMerge", r.rect); - } - } - Dialog::RenameKeyword { from, to } => { - let n = app.caches.dialog_count(&app.session.catalog, ("renameKeyword", from.as_str()), || { - app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count() - }); - if dialog_field(ui, "field:keywordName", to, "New name") { - confirm = true; - } - ui.label( - egui::RichText::new(crate::i18n::tr_format!("Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.", - if n == 1 { "" } else { "s" }, from = from, n = n)) - .color(t.text_dim), - ); - } - Dialog::MergeKeywords { from, into } => { - ui.label(egui::RichText::new(crate::i18n::tr_format!("Replace {} with:", from.iter().map(|f| format!("“{f}”")).collect::>().join(", "))).color(t.text_label)); - if dialog_field(ui, "field:keywordInto", into, "Keyword") { - confirm = true; - } - let options: Vec = app - .caches - .suggestions(&app.session.catalog, from, into, 12) - .iter() - .filter(|k| !from.iter().any(|f| lightcraft_catalog::keywords::is_under(k, f))) - .cloned() - .collect(); - ui.horizontal_wrapped(|ui| { - for k in options { - if ui.add(egui::Button::new(egui::RichText::new(&k).color(t.text_label)).corner_radius(10.0)).clicked() { - *into = k; - } - } - }); - } - Dialog::TextPrompt { value, hint, .. } => { - if dialog_field(ui, "field:prompt", value, hint) { - confirm = true; - } - } - Dialog::NewAlbum { name, folder, include_selected, .. } => { - if dialog_field(ui, "field:albumName", name, "Name") { - confirm = true; - } - // as in Lightroom, the option is only there when something is selected - if !*folder && !app.session.selected().is_empty() { - ui.add_space(6.0); - let r = ui.checkbox(include_selected, crate::i18n::tr("Include selected photos")); - crate::widgets::register(ui, "check:albumIncludeSelected", r.rect); - } - } - Dialog::RenameAlbum { name, .. } => { - if dialog_field(ui, "field:albumName", name, "Name") { - confirm = true; - } - } - Dialog::CreatePreset { name, group, groups } => { - crate::widgets::named(ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Preset name")).desired_width(f32::INFINITY)), crate::i18n::tr("Preset name")); - crate::widgets::named(ui.add(egui::TextEdit::singleline(group).hint_text(crate::i18n::tr("Group")).desired_width(f32::INFINITY)), crate::i18n::tr("Group")); - ui.label(egui::RichText::new(crate::i18n::tr("Settings to include")).color(t.text_dim)); - group_checklist(ui, "presetInclude", groups); - } - Dialog::CopySettings { groups } => group_checklist(ui, "copyGroup", groups), - Dialog::PasteSettings { groups } => { - let n = app.session.selected().len(); - ui.label( - egui::RichText::new(crate::i18n::tr_format!("Paste into {n} photo{} — only settings that were copied are pasted", if n == 1 { "" } else { "s" }, n = n)) - .color(t.text_dim), - ); - group_checklist(ui, "pasteGroup", groups); - } - Dialog::SlideshowExport { settings } => slideshow_export_body(app, ui, settings), - Dialog::ContactSheet { options } => { - options.columns = options.columns.clamp(1, 8); - options.rows = options.rows.clamp(1, 10); - let n = app.session.selected().len(); - ui.label(crate::i18n::tr_format!("{n} selected photos · {pages} pages", n = n, pages = n.div_ceil(options.columns * options.rows))); - field(ui, "Paper", |ui| { - let c = crate::widgets::combo_box("contactSheetPaper").selected_text(&options.paper).show_ui(ui, |ui| { - ui.selectable_value(&mut options.paper, "a4".into(), "A4"); - ui.selectable_value(&mut options.paper, "letter".into(), crate::i18n::tr("Letter")); - }); - crate::widgets::name_combo(&c.response, crate::i18n::tr("Paper")); - }); - ui.checkbox(&mut options.landscape, crate::i18n::tr("Landscape")); - field(ui, "Columns", |ui| { ui.add(egui::DragValue::new(&mut options.columns).range(1..=8)); }); - field(ui, "Rows", |ui| { ui.add(egui::DragValue::new(&mut options.rows).range(1..=10)); }); - ui.checkbox(&mut options.captions, crate::i18n::tr("Filename captions")); - ui.label(crate::i18n::tr("150 dpi · sRGB · current edits · fit without cropping")); - ui.label(crate::i18n::tr("Choose a PDF destination after confirming. Print the PDF from your viewer.")); - } - Dialog::Export { opts, full_size, resize, preset_name, limit_kb, dir } => { - use lightcraft_engine::export::{ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S}; - let n = app.session.selection.ids.len().max(1); - ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); - // Preset: load a built-in or saved set of options into the dialog - field(ui, "Preset", |ui| { - let mut chosen = None; - let c = crate::widgets::combo_box("exportPreset").width(220.0).selected_text(crate::i18n::tr("Choose…")).show_ui(ui, |ui| { - let mut builtin = true; - for (p, b) in app.session.all_export_presets() { - if builtin && !b { - ui.separator(); - } - builtin = b; - if ui.selectable_label(false, crate::i18n::builtin_label(&p.name, b)).clicked() { - chosen = Some(p.name); - } - } - }); - crate::widgets::name_combo(&c.response, crate::i18n::tr("Preset")); - crate::widgets::register(ui, "combo:exportPreset", c.response.rect); - if let Some(name) = chosen - && let Ok(params) = app.session.export_params(&json!({"preset": name})) - { - let o = lightcraft_engine::export::ExportOptions::from_json(¶ms); - *full_size = o.resize.is_none(); - *resize = o.resize.unwrap_or_default(); - *limit_kb = o.limit_kb.unwrap_or(0); - *opts = o; - } - }); - ui.add_space(4.0); - let before = opts.format; - // "Rendered DNG" is a DNG option (below), not a format of its own in this list - let mut shown = if opts.format == F::RenderedDng { F::Dng } else { opts.format }; - choices( - ui, - "Format", - "exportFormat", - &[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Psd, "PSD"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Jxl, "JPEG XL"), (F::Dng, "DNG"), (F::Original, "Original files")], - &mut shown, - ); - if shown != opts.format && !(shown == F::Dng && opts.format == F::RenderedDng) { - opts.format = shown; - } - if matches!(opts.format, F::Dng | F::RenderedDng) { - choices(ui, "DNG of", "exportDngKind", &[(F::Dng, "Raw data"), (F::RenderedDng, "Rendered photo")], &mut opts.format); - } - if !opts.format.is_rendered() { - let note = if opts.format == F::Dng { - "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply." - } else { - "The original files, unchanged, each with an XMP sidecar holding its edits." - }; - ui.label(egui::RichText::new(note).color(t.text_dim)); - } - if opts.format != before { - // each format starts at its own default depth (TIFF 16-bit, others 8-bit) - opts.bit_depth = None; - } - let rendered = opts.format.is_rendered(); - let depths = lightcraft_engine::export::ExportOptions::bit_depths(opts.format); - if rendered && depths.len() > 1 { - let mut bd = opts.bit_depth.filter(|b| depths.iter().any(|d| d.0 == *b)).unwrap_or(depths[0].0); - choices(ui, "Bit depth", "exportBitDepth", depths, &mut bd); - opts.bit_depth = Some(bd); - } - if matches!(opts.format, F::Jpeg | F::Tiff | F::Avif | F::Png | F::Jxl) { - let tip = crate::i18n::tr("Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float"); - ui.checkbox(&mut opts.hdr, crate::i18n::tr("HDR output")).on_hover_text(tip); - if opts.hdr && !opts.hdr_output() { - ui.label(egui::RichText::new(crate::i18n::tr("HDR TIFF needs 32-bit float.")).color(t.text_dim)); - } - if opts.hdr && opts.format == F::Avif { - use lightcraft_engine::export::HdrCurve as H; - choices(ui, "HDR curve", "exportHdrCurve", &[(H::Pq, "PQ"), (H::Hlg, "HLG")], &mut opts.hdr_transfer); - } - } - if !rendered { - } else if opts.format == F::Avif && opts.hdr { - let label = if opts.hdr_transfer == lightcraft_engine::export::HdrCurve::Hlg { - crate::i18n::tr("Color space: Rec. 2020 HLG (HDR AVIF)") - } else { - crate::i18n::tr("Color space: Rec. 2020 PQ (HDR AVIF)") - }; - ui.label(egui::RichText::new(label).color(t.text_dim)); - } else if opts.format == F::Png && opts.hdr { - ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ, 16-bit (HDR PNG)")).color(t.text_dim)) - .on_hover_text(crate::i18n::tr("Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).")); - } else if opts.format == F::Jxl && opts.hdr { - ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ (HDR JPEG XL)")).color(t.text_dim)); - } else if opts.format == F::Avif { - ui.label(egui::RichText::new(crate::i18n::tr("Color space: sRGB (AVIF)")).color(t.text_dim)); - } else { - use lightcraft_engine::export::OutputSpace as C; - choices( - ui, - "Color space", - "exportColorSpace", - &[ - (C::Srgb, "sRGB"), - (C::DisplayP3, "P3"), - (C::AdobeRgb, "Adobe RGB"), - (C::ProPhoto, "ProPhoto"), - (C::Rec2020, "Rec.2020"), - ], - &mut opts.color_space, - ); - if opts.effective_space() != opts.color_space { - let note = "Lossy JPEG XL writes Adobe RGB and ProPhoto as Rec. 2020; choose Lossless to keep them."; - ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)); - } - } - if opts.format == F::Jxl { - let tip = "Mathematically lossless: every pixel is kept exactly (larger files)"; - ui.checkbox(&mut opts.jxl_lossless, crate::i18n::tr("Lossless")).on_hover_text(crate::i18n::tr(tip)); - } - if matches!(opts.format, F::Jpeg | F::Avif) || (opts.format == F::Jxl && !opts.jxl_lossless) { - let mut q = opts.quality as f64; - if num(ui, &QUALITY, &mut q) { - opts.quality = q as u8; - } - } - if opts.format == F::Jxl { - use lightcraft_engine::export::JxlEffort as E; - choices(ui, "Effort", "exportJxlEffort", &[(E::Fast, "Fast"), (E::Normal, "Normal")], &mut opts.jxl_effort); - let tip = "Shows a coarse version first and refines it as the file loads"; - ui.checkbox(&mut opts.jxl_progressive, crate::i18n::tr("Progressive")).on_hover_text(crate::i18n::tr(tip)); - } - if opts.format == F::Jpeg { - let mut k = *limit_kb as f64; - if num(ui, &LIMIT_KB, &mut k) { - *limit_kb = k as u32; - } - } - if rendered { - export_size(ui, full_size, resize, &mut opts.ppi); - } - if rendered { - choices( - ui, - "Sharpen", - "exportSharpen", - &[(S::None, "None"), (S::Screen, "Screen"), (S::Matte, "Matte"), (S::Glossy, "Glossy")], - &mut opts.sharpen, - ); - if opts.sharpen != S::None { - choices( - ui, - "Amount", - "exportSharpenAmount", - &[(A::Low, "Low"), (A::Standard, "Standard"), (A::High, "High")], - &mut opts.sharpen_amount, - ); - } - } - if rendered { - choices( - ui, - "Metadata", - "exportMetadata", - &[(M::All, "All"), (M::AllExceptCamera, "No camera"), (M::Copyright, "Copyright"), (M::None, "None")], - &mut opts.metadata, - ); - if !matches!(opts.metadata, M::None | M::Copyright) { - ui.checkbox(&mut opts.remove_location, crate::i18n::tr("Remove location info")); - } - let mut wm_on = opts.watermark.is_some(); - if ui.checkbox(&mut wm_on, crate::i18n::tr("Watermark")).changed() { - opts.watermark = wm_on.then(|| lightcraft_engine::export::Watermark { text: "© ".into(), ..Default::default() }); - } - let wm_shown = opts.watermark.clone(); - if let Some(wm) = &wm_shown { - watermark_preview(app, ui, wm, (!*full_size).then_some(*resize)); - } - if let Some(wm) = &mut opts.watermark { - // text, or a graphic (a logo with transparency) - let mut graphic = !wm.image.is_empty() || ui.data(|d| d.get_temp::(egui::Id::unique("wm-graphic"))).unwrap_or(false); - let before = graphic; - choices(ui, "Style", "exportWmStyle", &[(false, "Text"), (true, "Graphic")], &mut graphic); - if graphic != before { - ui.data_mut(|d| d.insert_temp(egui::Id::unique("wm-graphic"), graphic)); - if !graphic { - wm.image.clear(); - } - } - if graphic { - field(ui, "Graphic", |ui| { - trailing_button_row(ui, |ui| { - if app.services.pick_files.is_some() - && crate::widgets::text_button(ui, "exportWmChoose", crate::i18n::tr("Choose…"), false).clicked() - && let Some(f) = app.services.pick_files.as_mut().and_then(|pick| pick().into_iter().next()) - { - wm.image = f; - } - crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut wm.image).hint_text("logo.png").desired_width(ui.available_width())), crate::i18n::tr("Image")); - }); - }); - let mut width = wm.image_width as f64 * 100.0; - if num(ui, &WM_IMAGE_WIDTH, &mut width) { - wm.image_width = (width / 100.0) as f32; - } - } else { - choices(ui, app.ui.language.tr("Text direction"), "exportWmOrientation", &[(false, app.ui.language.tr("Horizontal text")), (true, app.ui.language.tr("Vertical text"))], &mut wm.vertical); - field(ui, "Text", |ui| { - crate::widgets::named(ui.add(egui::TextEdit::multiline(&mut wm.text).desired_rows(2).hint_text(crate::i18n::tr("© Your Name")).desired_width(f32::INFINITY)), crate::i18n::tr("Text")) - }); - } - choices( - ui, - "Position", - "exportWmAnchor", - &WM_POSITIONS, - &mut wm.anchor, - ); - choices(ui, "Rotate", "exportWmRotation", &[(0u16, "0°"), (90, "90°"), (180, "180°"), (270, "270°")], &mut wm.rotation); - let mut size = wm.size as f64 * 100.0; - if !graphic && num(ui, &WM_SIZE, &mut size) { - wm.size = (size / 100.0) as f32; - } - let mut op = wm.opacity as f64 * 100.0; - if num(ui, &WM_OPACITY, &mut op) { - wm.opacity = (op / 100.0) as f32; - } - // like Lightroom Classic: a margin from the side edges and one from the top and bottom - // edges, each unused (greyed) when the position is centred along that axis - use lightcraft_engine::export::Anchor as P; - let across = !matches!(wm.anchor, P::Top | P::Center | P::Bottom); - if let Some(v) = crate::widgets::slider(ui, &WM_INSET_X, wm.inset_x as f64 * 100.0, across, None).value { - wm.inset_x = (v / 100.0) as f32; - } - let down = !matches!(wm.anchor, P::Left | P::Center | P::Right); - if let Some(v) = crate::widgets::slider(ui, &WM_INSET_Y, wm.inset_y as f64 * 100.0, down, None).value { - wm.inset_y = (v / 100.0) as f32; - } - if !graphic { - ui.checkbox(&mut wm.shadow, crate::i18n::tr("Shadow")); - } - } - if opts.watermark != wm_shown { - // the preview above was drawn with the old settings - ui.ctx().request_repaint(); - } - } - ui.add_space(4.0); - if opts.format == F::Tiff { - use lightcraft_engine::export::TiffCompression as Z; - choices(ui, "Compression", "exportTiffCompression", &[(Z::None, "None"), (Z::Lzw, "LZW"), (Z::Deflate, "ZIP")], &mut opts.tiff_compression); - } - if opts.format == F::Dng { - use lightcraft_engine::export::DngCompression as Z; - choices(ui, "Compression", "exportDngCompression", &[(Z::Lossless, "Lossless"), (Z::Deflate, "ZIP"), (Z::Uncompressed, "None"), (Z::Lossy, "Lossy")], &mut opts.dng_compression); - } - let naming_id = egui::Id::unique("export-naming"); - let tags_open = field(ui, "File name", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - let w = crate::import::tag_field_width(ui); - crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut opts.naming).id(naming_id).hint_text("{name}-{seq} · {date} · {title} · {folder}").desired_width(w)), crate::i18n::tr("File name")); - crate::import::tag_toggle(ui, "exportNaming") - }); - if tags_open { - crate::import::tag_help(ui, "exportNaming", &mut opts.naming, naming_id); - } - crate::import::unknown_tags_warning(ui, &opts.naming); - if opts.naming.contains("{seq") { - let mut v = opts.start_number as f64; - if num(ui, &START_NUMBER, &mut v) { - opts.start_number = v as u32; - } - } - // like Lightroom Classic's Export To: one folder, or each photo's own folder (not on - // the web, where originals live in the browser's storage, not in folders) - use lightcraft_engine::export::DestinationMode as D; - if !cfg!(target_arch = "wasm32") || opts.destination_mode != D::SpecificFolder { - choices( - ui, - "Export to", - "exportDestinationMode", - &[(D::SpecificFolder, "Specific folder"), (D::SameAsOriginal, "Same folder as original photo")], - &mut opts.destination_mode, - ); - } - if opts.destination_mode == D::SpecificFolder { - field(ui, "Folder", |ui| { - trailing_button_row(ui, |ui| { - if app.services.pick_folder.is_some() - && crate::widgets::text_button(ui, "exportChooseFolder", crate::i18n::tr("Choose…"), false).clicked() - && let Some(pick) = app.services.pick_folder.as_mut() - && let Some(d) = pick() - { - *dir = d; - } - crate::widgets::named(ui.add(egui::TextEdit::singleline(dir).desired_width(ui.available_width())), crate::i18n::tr("Folder")); - }); - }); - } - field(ui, "Subfolder", |ui| { - crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), crate::i18n::tr("Subfolder")) - }); - use lightcraft_engine::export::Conflict as K; - choices( - ui, - "If file exists", - "exportConflict", - &[(K::Unique, "Add number"), (K::Overwrite, "Overwrite"), (K::Skip, "Skip")], - &mut opts.conflict, - ); - // After export: Apple Photos imports the files written (macOS only) - #[cfg(target_os = "macos")] - { - let r = ui.checkbox(&mut opts.add_to_photos, crate::i18n::tr("Add to Apple Photos")); - crate::widgets::register(ui, "exportAddToPhotos", r.rect); - if opts.add_to_photos { - field(ui, "Album", |ui| { - crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut opts.photos_album).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), crate::i18n::tr("Album")) - }); - ui.label( - egui::RichText::new(crate::i18n::tr("The first time, macOS asks whether LightCraft may control Photos.")) - .size(11.0) - .color(t.text_dim), - ); - } - } - // nowhere to add them: a setting carried over from a Mac is dropped, not refused - #[cfg(not(target_os = "macos"))] - { - opts.add_to_photos = false; - } - ui.add_space(4.0); - field(ui, "Save preset", |ui| { - trailing_button_row(ui, |ui| { - let named = !preset_name.trim().is_empty(); - if crate::widgets::text_button(ui, "exportSavePreset", crate::i18n::tr("Save"), false).clicked() && named { - let params = export_dialog_params(opts, *full_size, resize, *limit_kb); - match app.run("export.savePreset", json!({"name": preset_name.trim(), "params": params})) { - Ok(_) => { - app.toast(ui.ctx(), crate::i18n::tr_format!("Saved export preset “{}”", preset_name.trim())); - preset_name.clear(); - } - Err(e) => app.toast(ui.ctx(), e), - } - } - crate::widgets::named(ui.add(egui::TextEdit::singleline(preset_name).hint_text(crate::i18n::tr("Preset name")).desired_width(ui.available_width())), crate::i18n::tr("Preset name")); - }); - }); - } - Dialog::Merge { opts } => crate::merge::body(app, ui, opts), - Dialog::Import { opts } => crate::import::body(app, ui, opts), - // (Settings is never taller than `body_max`, so the scroll area round it never - // scrolls: its tab bar stays put and its pages scroll by themselves) - Dialog::Settings { tab } => crate::panels::settings::window(app, ui, tab, body_max), - Dialog::SamModel { error, .. } => sam_model_body(app, ui, error.as_deref()), - Dialog::KeywordTag { - editing, - name, - parent, - inside, - synonyms, - include_on_export, - export_containing, - export_synonyms, - person, - add_to_selected, - .. - } => { - ui.label(egui::RichText::new(crate::i18n::tr("Keyword Name")).color(t.text_dim)); - // several fields: the name takes the focus when the dialog opens, not after - let r = crate::text_field::TextField::singleline("field:keywordTagName", name) - .hint(crate::i18n::tr("Keyword Name")) - .width(f32::INFINITY) - .select_on_focus(true) - .show(ui); - if ui.is_enabled() && just_opened(ui, "keyword-tag") { - r.response.request_focus(); - } - if r.ending == Some(crate::text_field::Ending::Return) { - confirm = true; - } - ui.add_space(4.0); - ui.label(egui::RichText::new(crate::i18n::tr("Synonyms")).color(t.text_dim)); - // Return in either field creates (or saves) the keyword - let r = crate::text_field::TextField::singleline("field:keywordSynonyms", synonyms) - .hint(crate::i18n::tr("Separated by commas")) - .width(f32::INFINITY) - .show(ui); - if r.ending == Some(crate::text_field::Ending::Return) { - confirm = true; - } - ui.add_space(6.0); - if editing.is_none() { - if let Some(p) = parent { - let r = ui.checkbox(inside, crate::i18n::tr_format!("Put inside “{parent}”", parent = p.replace('|', " › "))); - crate::widgets::register(ui, "check:keywordInside", r.rect); - } - let n = app.session.selection.ids.len(); - let r = ui.add_enabled(n > 0, egui::Checkbox::new(add_to_selected, crate::i18n::tr("Add to Selected Photos"))); - crate::widgets::register(ui, "check:keywordAddToSelected", r.rect); - ui.add_space(4.0); - } - ui.label(egui::RichText::new(crate::i18n::tr("Keyword Tag Options")).color(t.text_dim)); - for (on, label, id) in [ - (include_on_export, "Include on Export", "check:keywordIncludeOnExport"), - (export_containing, "Export Containing Keywords", "check:keywordExportContaining"), - (export_synonyms, "Export Synonyms", "check:keywordExportSynonyms"), - (person, "Person", "check:keywordPerson"), - ] { - let r = ui.checkbox(on, crate::i18n::tr(label)); - crate::widgets::register(ui, id, r.rect); - } - } - Dialog::KeywordSet { replaces, name, slots, as_new } => { - ui.label(egui::RichText::new(crate::i18n::tr("Set name")).color(t.text_dim)); - let r = crate::text_field::TextField::singleline("field:keywordSetName", name) - .hint(crate::i18n::tr("Set name")) - .width(f32::INFINITY) - .select_on_focus(true) - .show(ui); - if ui.is_enabled() && just_opened(ui, "keyword-set") { - r.response.request_focus(); - } - let mut returned = r.ending == Some(crate::text_field::Ending::Return); - ui.add_space(6.0); - ui.label(egui::RichText::new(crate::i18n::tr("Keywords (⌥1–⌥9)")).color(t.text_dim)); - slots.resize(9, String::new()); - // equal columns of a set width (the window is still finding its own) - let w = 130.0; - egui::Grid::new("keyword-set-slots").num_columns(3).min_col_width(w).spacing([6.0, 4.0]).show(ui, |ui| { - for (i, slot) in slots.iter_mut().enumerate() { - let r = crate::text_field::TextField::singleline(&format!("field:keywordSetSlot:{}", i + 1), slot) - .hint(format!("⌥{}", i + 1)) - .width(w) - .show(ui); - returned |= r.ending == Some(crate::text_field::Ending::Return); - if i % 3 == 2 { - ui.end_row(); - } - } - }); - if replaces.is_some() { - ui.add_space(6.0); - let r = ui.checkbox(as_new, crate::i18n::tr("Save as a new set")); - crate::widgets::register(ui, "check:keywordSetAsNew", r.rect); - } - // Return in any field saves the set - if returned { - confirm = true; - } - } - Dialog::MoveKeyword { keyword, parent } => { - let name = keyword.rsplit('|').next().unwrap_or(keyword); - match parent { - Some(p) => ui.label(crate::i18n::tr_format!( - "“{parent}” has a “{name}” already. Merge the two?", - parent = p.replace('|', " › "), - name = name - )), - None => ui.label(crate::i18n::tr_format!("There is a “{name}” at the top level already. Merge the two?", name = name)), - }; - ui.label(egui::RichText::new(crate::i18n::tr("Their photos and the keywords below them come together under one keyword.")).color(t.text_dim)); - } - Dialog::DeleteKeyword { keyword, count } => { - let name = keyword.replace('|', " › "); - ui.label(crate::i18n::tr_format!("Delete the keyword “{name}”?", name = name)); - ui.label( - egui::RichText::new(if *count == 0 { - crate::i18n::tr("No photo has it; the keywords below it go too.").to_string() - } else { - crate::i18n::tr_format!( - "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.", - if *count == 1 { "" } else { "s" }, - count = count - ) - }) - .color(t.text_dim), - ); - } - Dialog::SaveEffect { name, group, fields, amount, brush } => { - if crate::panels::mask_effects::save_body(app, ui, name, group, fields, amount, brush) { - confirm = true; - } - } - Dialog::ManageEffects { search, selected, rename, confirm_delete, new_group } => { - crate::panels::mask_effects::manage_body(app, ui, search, selected, rename, confirm_delete, new_group) - } - Dialog::DeleteEffects { names } => { - let what = match names.as_slice() { - [one] => crate::i18n::tr_format!("Delete the effect preset “{name}”?", name = one), - _ => crate::i18n::tr_format!("Delete these {n} effect presets?", n = names.len()), - }; - ui.label(what); - ui.label(egui::RichText::new(crate::i18n::tr("Masks keep their sliders. Export presets first to keep a copy.")).color(t.text_dim)); - } - Dialog::ConfirmDelete { count } => { - let what = if *count == 1 { crate::i18n::tr("this photo").to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) }; - ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what)); - ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim)); - } - Dialog::MovePhotos { ids, name, dont_ask, .. } => { - let n = ids.len(); - ui.label(crate::i18n::tr_format!("Move {n} photo{} to “{}”?", if n == 1 { "" } else { "s" }, name, n = n)); - ui.label(egui::RichText::new(crate::i18n::tr("Their files and XMP sidecars move on disk. Edit → Undo moves them back.")).color(t.text_dim)); - ui.add_space(4.0); - let c = ui.checkbox(dont_ask, crate::i18n::tr("Don't ask again")); - crate::widgets::register(ui, "check:moveDontAsk", c.rect); - c.on_hover_text(crate::i18n::tr("Settings → Confirm before moving photos to another folder on disk turns it back on")); - } - d @ Dialog::SynchronizeFolder { .. } => crate::sync::body(app, ui, d), - Dialog::LightroomCatalog { folder, catalog } => { - ui.label(crate::i18n::tr("This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?")); - ui.label(egui::RichText::new(catalog.as_deref().unwrap_or(folder.as_str())).color(t.text_dim)); - // (the import then asks where the catalog goes, unless Settings says to merge, #2672) - let asks = app.ui.settings.lightroom_import == crate::state::LightroomImport::Ask - && app.session.library.as_ref().is_some_and(|l| l.on_disk); - let what = if asks { - "The import reads the catalog without changing it. Next, choose whether its photos, edits, keywords and collections join the library open now or go into a new library." - } else { - "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now." - }; - ui.label(egui::RichText::new(crate::i18n::tr(what)).color(t.text_dim)); - } - d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::body(app, ui, d), - Dialog::RemoveFolder { name, count, path, disk } => { - ui.label(crate::i18n::tr_format!( - "Remove “{name}” and its {count} photo{} from the library?", - if *count == 1 { "" } else { "s" }, - name = name, - count = count - )); - ui.label(egui::RichText::new(path.as_str()).color(t.text_dim)); - let scope = if *disk { "All the photos imported from this disk are included." } else { "That includes the photos in the folders inside it." }; - ui.label(egui::RichText::new(crate::i18n::tr(scope)).color(t.text_dim)); - ui.label( - egui::RichText::new(crate::i18n::tr("They move to Recently Deleted and can be restored; no file on disk is touched.")) - .color(t.text_dim), - ); - } - Dialog::About => { - ui.set_min_width(680.0); - let tab_id = egui::Id::unique("about_tab"); - let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; - for (i, (id, label)) in ABOUT_TABS.iter().enumerate() { - if crate::widgets::text_button(ui, &format!("aboutTab-{id}"), label, usize::from(tab) == i).clicked() { - tab = u8::try_from(i).unwrap_or(0); - } - } - }); - ui.data_mut(|d| d.insert_temp(tab_id, tab)); - ui.separator(); - match tab { - 1 => crate::credits::contributors_ui(app, ui), - 2 => crate::credits::models_ui(ui), - _ => { - ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text)); - ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION"))); - ui.label(crate::i18n::tr_format!( - "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", - crate::theme::font_credits(app.chinese_font.is_some()) - )); - ui.add_space(10.0); - let r = crate::button::Button::new("Join the ArtCraft Discord") - .primary() - .large() - .min_width(260.0) - .widget("button:aboutDiscord") - .tooltip(crate::links::DISCORD) - .show(ui); - if r.clicked() { - let _ = crate::links::open(app, crate::links::DISCORD); - } - ui.add_space(6.0); - for (label, url) in [ - ("LightCraft website", crate::links::APP_PAGE), - ("Source code on GitHub", crate::links::GITHUB), - ("ArtCraft — more creative apps", crate::links::WEBSITE), - ] { - let r = ui.link(crate::i18n::tr(label)).on_hover_text(url); - if r.clicked() { - let _ = crate::links::open(app, url); - } - } - } - } - } - Dialog::Shortcuts => crate::panels::keymap::body(app, ui, &t), - Dialog::ViewOptions => crate::panels::settings::view_options(app, ui, &t), - Dialog::Print => crate::print_view::body(app, ui, &t), - }); + body_h = dialog_body(ui, (!import && !print).then_some(title.as_str()), body_max, |ui| dialog_content(app, ui, &t, body_max, &mut confirm, &mut dlg)); ui.add_space(4.0); // the confirm on the right with Cancel to its left (macOS and GNOME's order), filled with // the accent when it is the dialog's primary action. The row spans the width the window @@ -1495,3 +453,1268 @@ const BACKDROP_SHOWN: &str = "dialog-backdrop-shown"; pub fn keeps_open(app: &LightcraftApp, dlg: &Dialog) -> bool { matches!(dlg, Dialog::SamModel { .. }) && !app.session.segmenter.installed() } + +/// The body of the AutoStack dialog. +fn auto_stack_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::AutoStack { gap } = dlg else { return }; + ui.label(egui::RichText::new(crate::i18n::tr("Stack photos taken within this time of each other:")).color(t.text_label)); + let r = ui.add( + egui::Slider::new(gap, 0.0..=86400.0) + .logarithmic(true) + .smallest_positive(1.0) + .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) + .custom_parser(crate::panels::dialogs::parse_gap), + ); + crate::widgets::named(r, crate::i18n::tr("Stack photos taken within this time of each other:")); + let preview = app.session.execute("stack.auto", &json!({"gap": *gap, "preview": true})).unwrap_or_default(); + let scope = if app.session.selection.ids.len() > 1 { "the selected photos" } else { "the photos in view" }; + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "Creates {} stacks from {} of {scope}", + preview["stacks"], + preview["photos"], + scope = crate::i18n::tr(scope) + )) + .color(t.text_dim), + ); +} + +/// The body of the Cull dialog. +fn cull_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::Cull { reject_below, pick_best, burst_gap, similarity } = dlg else { return }; + let n = app.session.selection.ids.len(); + let scope = if n > 1 { + crate::i18n::tr_format!("the {n} selected photos", n = n) + } else { + crate::i18n::tr_format!("the {} photos in view", app.session.visible_cloned().len()) + }; + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).", + scope = scope + )) + .color(t.text_label), + ); + ui.add_space(6.0); + let r = ui.add(egui::Slider::new(reject_below, 0.0..=80.0).text(crate::i18n::tr("Reject below focus")).step_by(1.0)); + crate::widgets::register(ui, "field:cullReject", r.rect); + ui.label( + egui::RichText::new(crate::i18n::tr(if *reject_below > 0.0 { + "Blurry photos below the score are flagged as rejects." + } else { + "0: nothing is rejected." + })) + .color(t.text_dim), + ); + let r = ui.checkbox(pick_best, crate::i18n::tr("Pick the sharpest photo of each burst")); + crate::widgets::register(ui, "check:cullPick", r.rect); + ui.add_space(6.0); + // the sliders drag over the usual range; their value fields take whatever + // `photo.analyze` accepts (a gap of 0 s to a day, a similarity of 0 to 1) + let r = ui.add( + egui::Slider::new(burst_gap, 1.0..=600.0) + .logarithmic(true) + .smallest_positive(1.0) + .clamping(egui::SliderClamping::Never) + .text(crate::i18n::tr("Burst: shots at most this far apart")) + .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) + .custom_parser(crate::panels::dialogs::parse_gap), + ); + crate::widgets::register(ui, "field:cullGap", r.rect); + let r = ui.add( + egui::Slider::new(similarity, 0.70..=0.99) + .clamping(egui::SliderClamping::Never) + .text(crate::i18n::tr("Burst: how alike (loose … strict)")) + // a typed 0.995 is kept (whole hundredths would round it to 1.00) + .step_by(0.001) + .min_decimals(2) + .max_decimals(3), + ); + crate::widgets::register(ui, "field:cullSimilar", r.rect); + clamp_cull_burst(burst_gap, similarity); + ui.label(egui::RichText::new(crate::i18n::tr("Lower it when a panning or moving burst splits into several groups.")).color(t.text_dim)); + ui.label( + egui::RichText::new(crate::i18n::tr("Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.")) + .color(t.text_dim), + ); +} + +/// The body of the WhatsNew dialog. +fn whats_new_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::WhatsNew = dlg else { return }; + egui::ScrollArea::vertical().max_height(list_height(ui, 460.0)).auto_shrink([false, true]).show(ui, |ui| { + for line in WHATS_NEW.lines() { + let l = line.trim_end(); + if let Some(h) = l.strip_prefix("### ") { + ui.add_space(6.0); + ui.label(egui::RichText::new(crate::i18n::tr(h)).font(t.semibold(12.5)).color(t.text)); + } else if let Some(h) = l.strip_prefix("## ") { + ui.add_space(8.0); + ui.label(egui::RichText::new(crate::i18n::tr(h)).font(t.semibold(14.0)).color(t.text)); + } else if l.starts_with("# ") || l.is_empty() { + } else if let Some(b) = l.strip_prefix("- ") { + ui.label(egui::RichText::new(format!("• {}", crate::i18n::tr(b).replace('`', ""))).color(t.text_label)); + } else { + ui.label(egui::RichText::new(crate::i18n::tr(l.trim()).replace('`', "")).color(t.text_label)); + } + } + }); +} + +/// The body of the LightroomResult dialog. +fn lightroom_result_body(ui: &mut egui::Ui, dlg: &mut Dialog) { + let Dialog::LightroomResult { summary, library, details, .. } = dlg else { return }; + ui.label(summary.as_str()); + ui.add_space(8.0); + if let Some(library) = library { + ui.label(crate::i18n::tr("Destination library")); + ui.add(egui::Label::new(library.as_str()).wrap().selectable(true)); + ui.label(crate::i18n::tr("Lightroom catalogs import into the open library; no separate catalog folder is created.")); + } else { + ui.label(crate::i18n::tr("These photos are only in memory. Open a library on disk and import the catalog again to save them.")); + } + for detail in details.iter() { + ui.add(egui::Label::new(detail).wrap().selectable(true)); + } +} + +/// The body of the SystemInfo dialog. +fn system_info_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::SystemInfo { rows } = dlg else { return }; + egui::Grid::new("sysinfo").num_columns(2).spacing([16.0, 4.0]).striped(true).show(ui, |ui| { + for (k, v) in rows.iter() { + ui.label(egui::RichText::new(crate::i18n::tr(k)).color(t.text_dim)); + ui.add(egui::Label::new(egui::RichText::new(v).color(t.text_label)).wrap()); + ui.end_row(); + } + }); + if ui.button(crate::i18n::tr("Copy to Clipboard")).clicked() { + let text: String = rows.iter().map(|(k, v)| format!("{k}: {v}\n")).collect(); + ui.ctx().copy_text(text); + } +} + +/// The body of the AllMetadata dialog. +fn all_metadata_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::AllMetadata { title, rows, search } = dlg else { return }; + ui.label(egui::RichText::new(title.as_str()).color(t.text_label)); + let r = crate::widgets::named( + ui.add(egui::TextEdit::singleline(search).hint_text(crate::i18n::tr("Filter fields")).desired_width(f32::INFINITY)), + crate::i18n::tr("Filter fields"), + ); + crate::widgets::register(ui, "field:metadataSearch", r.rect); + let q = search.trim().to_lowercase(); + let keep = |n: &str, v: &str| q.is_empty() || n.to_lowercase().contains(&q) || v.to_lowercase().contains(&q); + let mut groups: Vec<(String, Vec<(String, String)>)> = Vec::new(); + for row in rows["exif"].as_array().into_iter().flatten() { + let (g, n, v) = (row["group"].as_str().unwrap_or(""), row["name"].as_str().unwrap_or(""), row["value"].as_str().unwrap_or("")); + if !keep(n, v) { + continue; + } + match groups.iter_mut().find(|(x, _)| x == g) { + Some((_, list)) => list.push((n.into(), v.into())), + None => groups.push((g.into(), vec![(n.into(), v.into())])), + } + } + let xmp: Vec<(String, String)> = rows["xmp"] + .as_array() + .into_iter() + .flatten() + .map(|r| (r["name"].as_str().unwrap_or("").to_string(), r["value"].as_str().unwrap_or("").to_string())) + .filter(|(n, v)| keep(n, v)) + .collect(); + if !xmp.is_empty() { + groups.push(("XMP".into(), xmp)); + } + egui::ScrollArea::vertical().max_height(list_height(ui, 440.0)).auto_shrink([false, true]).show(ui, |ui| { + if groups.is_empty() { + ui.label(egui::RichText::new(crate::i18n::tr(rows["note"].as_str().unwrap_or("No metadata found"))).color(t.text_dim)); + } + for (g, list) in &groups { + ui.add_space(6.0); + ui.label(egui::RichText::new(g).font(t.semibold(12.5)).color(t.text)); + egui::Grid::new(format!("meta-{g}")).num_columns(2).spacing([16.0, 3.0]).striped(true).show(ui, |ui| { + for (n, v) in list { + ui.label(egui::RichText::new(n).color(t.text_dim)); + ui.add(egui::Label::new(egui::RichText::new(v).color(t.text_label)).wrap()); + ui.end_row(); + } + }); + } + }); +} + +/// The body of the SmartRules dialog. +fn smart_rules_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::SmartRules { id, name, rules, .. } = dlg else { return }; + let r = crate::widgets::named( + ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY)), + crate::i18n::tr("Name"), + ); + crate::widgets::register(ui, "field:smartName", r.rect); + ui.add_space(6.0); + // the folder an album made from a folder view carries is not in the editor, but it counts + let folder = id + .and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id))) + .and_then(|a| a.smart.as_deref().and_then(|f| f.library_folder.clone())); + // problems, count and albums: cached until the catalog or the rules change (not per frame) + let now = (app.session.clock)(); + let view = app.caches.rules_view(&app.session.catalog, rules, *id, folder, &now); + let env = crate::panels::rules_editor::Env { problems: view.problems.clone(), today: now, albums: view.albums.clone() }; + egui::ScrollArea::vertical().max_height(list_height(ui, 360.0)).auto_shrink([false, true]).show(ui, |ui| { + crate::panels::rules_editor::edit(ui, rules, "rules", 0, &[], &env); + }); + let n = view.count; + ui.add_space(4.0); + if view.problems.is_empty() { + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "{n} photo{} match · updates automatically as photos change", + if n == 1 { "" } else { "s" }, + n = n + )) + .color(t.text_dim), + ); + } else { + ui.label(egui::RichText::new(crate::i18n::tr("Fix the marked rules to save this album.")).color(t.caution)); + } +} + +/// The body of the NewSmartAlbum dialog. +fn new_smart_album_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, confirm: &mut bool, dlg: &mut Dialog) { + let Dialog::NewSmartAlbum { name, .. } = dlg else { return }; + if dialog_field(ui, "field:smartAlbumName", name, "Name") { + *confirm = true; + } + let rules = app.session.view_rules(); + // counted when the library or the rules change (or, for "in the last…" rules, the minute), not every frame + let now = (app.session.clock)(); + let minute = if rules.depends_on_now() { now.get(..16).unwrap_or(&now) } else { "" }; + let n = app.caches.dialog_count(&app.session.catalog, ("smartAlbum", format!("{rules:?}"), minute), || { + lightcraft_catalog::rules::set_now(Some(now.clone())); + app.session.catalog.query(&rules, &Default::default()).len() + }); + ui.label( + egui::RichText::new(crate::i18n::tr_format!("Matches: {}", crate::i18n::filter_label(&rules, &app.session.catalog))).color(t.text_label), + ); + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "{n} photo{} now · updates automatically as photos change", + if n == 1 { "" } else { "s" }, + n = n + )) + .color(t.text_dim), + ); +} + +/// The body of the CaptureTime dialog. +fn capture_time_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::CaptureTime { mode, time, days, hours, minutes, zone } = dlg else { return }; + let n = app.session.selected().len(); + field(ui, "Change", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (i, (label, m)) in [("Set date & time", "set"), ("Shift by", "shift"), ("Time zone", "zone")].iter().enumerate() { + if crate::widgets::text_button(ui, &format!("captureMode-{i}"), label, mode == m).clicked() { + *mode = m.to_string(); + } + } + }); + let params = match mode.as_str() { + "set" => { + field(ui, "New time", |ui| { + let r = ui.add(egui::TextEdit::singleline(time).hint_text("2026-09-30 14:05:00").desired_width(f32::INFINITY)); + crate::widgets::register(ui, "field:captureTime", r.rect); + }); + json!({"time": time}) + } + "shift" => { + field(ui, "Shift", |ui| { + ui.add(egui::DragValue::new(days).suffix(" d")); + ui.add(egui::DragValue::new(hours).suffix(" h")); + ui.add(egui::DragValue::new(minutes).suffix(" min")); + }); + json!({"shift": *days as i64 * 86_400 + *hours as i64 * 3600 + *minutes as i64 * 60}) + } + _ => { + field(ui, "Hours", |ui| ui.add(egui::DragValue::new(zone).range(-26.0..=26.0).speed(0.25).fixed_decimals(2))); + json!({"hours": zone}) + } + }; + // preview on the active photo (the others move by the same amount) + let active = app.session.active().and_then(|id| app.session.catalog.photo(id)).map(|p| (p.file_name.clone(), p.date().to_string())); + if let Some((name, cur)) = active { + let delta = match mode.as_str() { + "set" => lightcraft_catalog::dates::normalize_iso(time) + .and_then(|t| Some(lightcraft_catalog::dates::iso_seconds(&t)? - lightcraft_catalog::dates::iso_seconds(&cur)?)), + "shift" => params["shift"].as_i64(), + _ => Some((*zone as f64 * 3600.0).round() as i64), + }; + // the photo's own time is the problem (a Lightroom catalog's capture time + // is copied verbatim), not what was typed + let unreadable = lightcraft_catalog::dates::shift_iso(&cur, 0).is_none(); + let after = match (mode.as_str(), unreadable && n == 1) { + // alone, it simply gets the time typed (photo.setCaptureTime repairs it) + ("set", true) => lightcraft_catalog::dates::normalize_iso(time), + _ => delta.and_then(|d| lightcraft_catalog::dates::shift_iso(&cur, d)), + }; + ui.label( + egui::RichText::new(match after { + Some(a) => format!("{name}: {} → {}", cur.replace('T', " "), a.replace('T', " ")), + None if unreadable && !(mode == "set" && n == 1) => crate::i18n::tr("The active photo's capture time can't be read").to_string(), + None => "Enter a date as YYYY-MM-DD HH:MM:SS".into(), + }) + .color(t.text_label), + ); + } + if n > 1 { + ui.label(egui::RichText::new(crate::i18n::tr_format!("All {n} selected photos move by the same amount.", n = n)).color(t.text_dim)); + } +} + +/// The body of the LabelNames dialog. +fn label_names_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::LabelNames { names, save_as } = dlg else { return }; + names.resize(5, String::new()); + // start from a set + let sets = lightcraft_engine::cmd::manage::label_sets_json(&app.session); + field(ui, "Set", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for set in sets["sets"].as_array().into_iter().flatten() { + let name = set["name"].as_str().unwrap_or_default(); + if crate::widgets::text_button(ui, &format!("labelSet-{name}"), name, false).clicked() { + *names = set["names"].as_array().into_iter().flatten().map(|n| n.as_str().unwrap_or_default().to_string()).collect(); + } + } + }); + for (i, l) in lightcraft_catalog::ColorLabel::ALL.iter().enumerate() { + let colour = format!("{l:?}"); + field(ui, &colour, |ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover()); + ui.painter().circle_filled(r.center(), 6.0, crate::panels::grid::label_color(*l)); + let te = crate::widgets::named( + ui.add(egui::TextEdit::singleline(&mut names[i]).hint_text(colour.as_str()).desired_width(f32::INFINITY)), + crate::i18n::tr(&colour), + ); + crate::widgets::register(ui, format!("field:labelName-{}", colour.to_lowercase()), te.rect); + }); + } + field(ui, "Save as set", |ui| { + let te = crate::widgets::named( + ui.add(egui::TextEdit::singleline(save_as).hint_text(crate::i18n::tr("Optional name")).desired_width(f32::INFINITY)), + crate::i18n::tr("Save as set"), + ); + crate::widgets::register(ui, "field:labelSetName", te.rect); + }); + ui.label( + egui::RichText::new(crate::i18n::tr( + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.", + )) + .color(t.text_dim), + ); +} + +/// The body of the Rename dialog. +fn rename_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::Rename { template, start } = dlg else { return }; + let n = app.session.selected().len(); + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); + let template_id = egui::Id::unique("rename-template"); + let tags_open = field(ui, "Template", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let w = crate::import::tag_field_width(ui); + let r = crate::widgets::named( + ui.add(egui::TextEdit::singleline(template).id(template_id).hint_text("{name}").desired_width(w)), + crate::i18n::tr("Template"), + ); + crate::widgets::register(ui, "field:renameTemplate", r.rect); + crate::import::tag_toggle(ui, "renameTemplate") + }); + if tags_open { + crate::import::tag_help(ui, "renameTemplate", template, template_id); + } + crate::import::unknown_tags_warning(ui, template); + field(ui, "Presets", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (i, (label, tpl)) in + [("Name", "{name}"), ("Date–Name", "{date}_{name}"), ("Title–Seq", "{title}-{seq:3}"), ("Custom–Seq", "Photo-{seq:3}")].iter().enumerate() + { + if crate::widgets::text_button(ui, &format!("renamePreset-{i}"), label, template == tpl).clicked() { + *template = tpl.to_string(); + } + } + }); + field(ui, "Start at", |ui| ui.add(egui::DragValue::new(start).range(0..=999_999))); + ui.label( + egui::RichText::new(crate::i18n::tr( + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…", + )) + .color(t.text_dim), + ); + let (preview, total) = rename_preview(app, ui.ctx(), template, *start as usize); + match &preview { + Some(rows) => { + egui::Grid::new("rename-preview").num_columns(3).spacing([8.0, 2.0]).show(ui, |ui| { + for pl in rows { + ui.label(egui::RichText::new(&pl.from).color(t.text_dim)); + let (arrow, _) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); + crate::icons::paint(ui.painter(), arrow, crate::icons::Icon::ChevronRight, t.text_dim); + ui.label(egui::RichText::new(&pl.to).color(t.text)); + ui.end_row(); + } + }); + } + None => { + ui.horizontal(|ui| { + crate::progress::spinner(ui, 12.0); + ui.label(egui::RichText::new(crate::i18n::tr("Checking names…")).color(t.text_dim)); + }); + } + } + let more = total.saturating_sub(RENAME_PREVIEW_ROWS); + if more > 0 { + ui.label(egui::RichText::new(crate::i18n::tr_format!("… and {more} more", more = more)).color(t.text_dim)); + } +} + +/// The body of the RenamePerson dialog. +fn rename_person_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, confirm: &mut bool, dlg: &mut Dialog) { + let Dialog::RenamePerson { from, to, merge } = dlg else { return }; + let submitted = dialog_field(ui, "field:personName", to, "Name"); + let plan = person_rename_plan(&app.session.catalog, from, to); + if !plan.needs_merge { + *merge = false; + } + if submitted && plan.block.is_none() && (!plan.needs_merge || *merge) { + *confirm = true; + } + if let Some(why) = plan.block { + ui.label(egui::RichText::new(crate::i18n::tr(why)).color(t.caution)); + } else { + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "Renames {n} face{} across the library. The files are not changed.", + if plan.faces == 1 { "" } else { "s" }, + n = plan.faces + )) + .color(t.text_dim), + ); + } + if plan.needs_merge { + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "“{to}” is already a person. Combining them makes one person; Undo separates them again.", + to = to.trim() + )) + .color(t.caution), + ); + let r = ui.checkbox(merge, crate::i18n::tr("Combine with the existing person")); + crate::widgets::register(ui, "check:personMerge", r.rect); + } +} + +/// The body of the RenameKeyword dialog. +fn rename_keyword_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, confirm: &mut bool, dlg: &mut Dialog) { + let Dialog::RenameKeyword { from, to } = dlg else { return }; + let n = app.caches.dialog_count(&app.session.catalog, ("renameKeyword", from.as_str()), || { + app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count() + }); + if dialog_field(ui, "field:keywordName", to, "New name") { + *confirm = true; + } + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.", + if n == 1 { "" } else { "s" }, + from = from, + n = n + )) + .color(t.text_dim), + ); +} + +/// The body of the MergeKeywords dialog. +fn merge_keywords_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, confirm: &mut bool, dlg: &mut Dialog) { + let Dialog::MergeKeywords { from, into } = dlg else { return }; + ui.label( + egui::RichText::new(crate::i18n::tr_format!("Replace {} with:", from.iter().map(|f| format!("“{f}”")).collect::>().join(", "))) + .color(t.text_label), + ); + if dialog_field(ui, "field:keywordInto", into, "Keyword") { + *confirm = true; + } + let options: Vec = app + .caches + .suggestions(&app.session.catalog, from, into, 12) + .iter() + .filter(|k| !from.iter().any(|f| lightcraft_catalog::keywords::is_under(k, f))) + .cloned() + .collect(); + ui.horizontal_wrapped(|ui| { + for k in options { + if ui.add(egui::Button::new(egui::RichText::new(&k).color(t.text_label)).corner_radius(10.0)).clicked() { + *into = k; + } + } + }); +} + +/// The body of the NewAlbum dialog. +fn new_album_body(app: &mut LightcraftApp, ui: &mut egui::Ui, confirm: &mut bool, dlg: &mut Dialog) { + let Dialog::NewAlbum { name, folder, include_selected, .. } = dlg else { return }; + if dialog_field(ui, "field:albumName", name, "Name") { + *confirm = true; + } + // as in Lightroom, the option is only there when something is selected + if !*folder && !app.session.selected().is_empty() { + ui.add_space(6.0); + let r = ui.checkbox(include_selected, crate::i18n::tr("Include selected photos")); + crate::widgets::register(ui, "check:albumIncludeSelected", r.rect); + } +} + +/// The body of the PasteSettings dialog. +fn paste_settings_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::PasteSettings { groups } = dlg else { return }; + let n = app.session.selected().len(); + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "Paste into {n} photo{} — only settings that were copied are pasted", + if n == 1 { "" } else { "s" }, + n = n + )) + .color(t.text_dim), + ); + group_checklist(ui, "pasteGroup", groups); +} + +/// The body of the ContactSheet dialog. +fn contact_sheet_body(app: &mut LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) { + let Dialog::ContactSheet { options } = dlg else { return }; + options.columns = options.columns.clamp(1, 8); + options.rows = options.rows.clamp(1, 10); + let n = app.session.selected().len(); + ui.label(crate::i18n::tr_format!("{n} selected photos · {pages} pages", n = n, pages = n.div_ceil(options.columns * options.rows))); + field(ui, "Paper", |ui| { + let c = crate::widgets::combo_box("contactSheetPaper").selected_text(&options.paper).show_ui(ui, |ui| { + ui.selectable_value(&mut options.paper, "a4".into(), "A4"); + ui.selectable_value(&mut options.paper, "letter".into(), crate::i18n::tr("Letter")); + }); + crate::widgets::name_combo(&c.response, crate::i18n::tr("Paper")); + }); + ui.checkbox(&mut options.landscape, crate::i18n::tr("Landscape")); + field(ui, "Columns", |ui| { + ui.add(egui::DragValue::new(&mut options.columns).range(1..=8)); + }); + field(ui, "Rows", |ui| { + ui.add(egui::DragValue::new(&mut options.rows).range(1..=10)); + }); + ui.checkbox(&mut options.captions, crate::i18n::tr("Filename captions")); + ui.label(crate::i18n::tr("150 dpi · sRGB · current edits · fit without cropping")); + ui.label(crate::i18n::tr("Choose a PDF destination after confirming. Print the PDF from your viewer.")); +} + +/// The body of the Export dialog. +fn export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::Export { opts, full_size, resize, preset_name, limit_kb, dir } = dlg else { return }; + use lightcraft_engine::export::{ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S}; + let n = app.session.selection.ids.len().max(1); + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); + // Preset: load a built-in or saved set of options into the dialog + field(ui, "Preset", |ui| { + let mut chosen = None; + let c = crate::widgets::combo_box("exportPreset").width(220.0).selected_text(crate::i18n::tr("Choose…")).show_ui(ui, |ui| { + let mut builtin = true; + for (p, b) in app.session.all_export_presets() { + if builtin && !b { + ui.separator(); + } + builtin = b; + if ui.selectable_label(false, crate::i18n::builtin_label(&p.name, b)).clicked() { + chosen = Some(p.name); + } + } + }); + crate::widgets::name_combo(&c.response, crate::i18n::tr("Preset")); + crate::widgets::register(ui, "combo:exportPreset", c.response.rect); + if let Some(name) = chosen + && let Ok(params) = app.session.export_params(&json!({"preset": name})) + { + let o = lightcraft_engine::export::ExportOptions::from_json(¶ms); + *full_size = o.resize.is_none(); + *resize = o.resize.unwrap_or_default(); + *limit_kb = o.limit_kb.unwrap_or(0); + *opts = o; + } + }); + ui.add_space(4.0); + let before = opts.format; + // "Rendered DNG" is a DNG option (below), not a format of its own in this list + let mut shown = if opts.format == F::RenderedDng { F::Dng } else { opts.format }; + choices( + ui, + "Format", + "exportFormat", + &[ + (F::Jpeg, "JPEG"), + (F::Png, "PNG"), + (F::Tiff, "TIFF"), + (F::Psd, "PSD"), + (F::Webp, "WebP"), + (F::Avif, "AVIF"), + (F::Jxl, "JPEG XL"), + (F::Dng, "DNG"), + (F::Original, "Original files"), + ], + &mut shown, + ); + if shown != opts.format && !(shown == F::Dng && opts.format == F::RenderedDng) { + opts.format = shown; + } + if matches!(opts.format, F::Dng | F::RenderedDng) { + choices(ui, "DNG of", "exportDngKind", &[(F::Dng, "Raw data"), (F::RenderedDng, "Rendered photo")], &mut opts.format); + } + if !opts.format.is_rendered() { + let note = if opts.format == F::Dng { + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply." + } else { + "The original files, unchanged, each with an XMP sidecar holding its edits." + }; + ui.label(egui::RichText::new(note).color(t.text_dim)); + } + if opts.format != before { + // each format starts at its own default depth (TIFF 16-bit, others 8-bit) + opts.bit_depth = None; + } + let rendered = opts.format.is_rendered(); + let depths = lightcraft_engine::export::ExportOptions::bit_depths(opts.format); + if rendered && depths.len() > 1 { + let mut bd = opts.bit_depth.filter(|b| depths.iter().any(|d| d.0 == *b)).unwrap_or(depths[0].0); + choices(ui, "Bit depth", "exportBitDepth", depths, &mut bd); + opts.bit_depth = Some(bd); + } + if matches!(opts.format, F::Jpeg | F::Tiff | F::Avif | F::Png | F::Jxl) { + let tip = crate::i18n::tr( + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float", + ); + ui.checkbox(&mut opts.hdr, crate::i18n::tr("HDR output")).on_hover_text(tip); + if opts.hdr && !opts.hdr_output() { + ui.label(egui::RichText::new(crate::i18n::tr("HDR TIFF needs 32-bit float.")).color(t.text_dim)); + } + if opts.hdr && opts.format == F::Avif { + use lightcraft_engine::export::HdrCurve as H; + choices(ui, "HDR curve", "exportHdrCurve", &[(H::Pq, "PQ"), (H::Hlg, "HLG")], &mut opts.hdr_transfer); + } + } + if !rendered { + } else if opts.format == F::Avif && opts.hdr { + let label = if opts.hdr_transfer == lightcraft_engine::export::HdrCurve::Hlg { + crate::i18n::tr("Color space: Rec. 2020 HLG (HDR AVIF)") + } else { + crate::i18n::tr("Color space: Rec. 2020 PQ (HDR AVIF)") + }; + ui.label(egui::RichText::new(label).color(t.text_dim)); + } else if opts.format == F::Png && opts.hdr { + ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ, 16-bit (HDR PNG)")).color(t.text_dim)) + .on_hover_text(crate::i18n::tr("Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).")); + } else if opts.format == F::Jxl && opts.hdr { + ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ (HDR JPEG XL)")).color(t.text_dim)); + } else if opts.format == F::Avif { + ui.label(egui::RichText::new(crate::i18n::tr("Color space: sRGB (AVIF)")).color(t.text_dim)); + } else { + use lightcraft_engine::export::OutputSpace as C; + choices( + ui, + "Color space", + "exportColorSpace", + &[(C::Srgb, "sRGB"), (C::DisplayP3, "P3"), (C::AdobeRgb, "Adobe RGB"), (C::ProPhoto, "ProPhoto"), (C::Rec2020, "Rec.2020")], + &mut opts.color_space, + ); + if opts.effective_space() != opts.color_space { + let note = "Lossy JPEG XL writes Adobe RGB and ProPhoto as Rec. 2020; choose Lossless to keep them."; + ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)); + } + } + if opts.format == F::Jxl { + let tip = "Mathematically lossless: every pixel is kept exactly (larger files)"; + ui.checkbox(&mut opts.jxl_lossless, crate::i18n::tr("Lossless")).on_hover_text(crate::i18n::tr(tip)); + } + if matches!(opts.format, F::Jpeg | F::Avif) || (opts.format == F::Jxl && !opts.jxl_lossless) { + let mut q = opts.quality as f64; + if num(ui, &QUALITY, &mut q) { + opts.quality = q as u8; + } + } + if opts.format == F::Jxl { + use lightcraft_engine::export::JxlEffort as E; + choices(ui, "Effort", "exportJxlEffort", &[(E::Fast, "Fast"), (E::Normal, "Normal")], &mut opts.jxl_effort); + let tip = "Shows a coarse version first and refines it as the file loads"; + ui.checkbox(&mut opts.jxl_progressive, crate::i18n::tr("Progressive")).on_hover_text(crate::i18n::tr(tip)); + } + if opts.format == F::Jpeg { + let mut k = *limit_kb as f64; + if num(ui, &LIMIT_KB, &mut k) { + *limit_kb = k as u32; + } + } + if rendered { + export_size(ui, full_size, resize, &mut opts.ppi); + } + if rendered { + choices( + ui, + "Sharpen", + "exportSharpen", + &[(S::None, "None"), (S::Screen, "Screen"), (S::Matte, "Matte"), (S::Glossy, "Glossy")], + &mut opts.sharpen, + ); + if opts.sharpen != S::None { + choices(ui, "Amount", "exportSharpenAmount", &[(A::Low, "Low"), (A::Standard, "Standard"), (A::High, "High")], &mut opts.sharpen_amount); + } + } + if rendered { + choices( + ui, + "Metadata", + "exportMetadata", + &[(M::All, "All"), (M::AllExceptCamera, "No camera"), (M::Copyright, "Copyright"), (M::None, "None")], + &mut opts.metadata, + ); + if !matches!(opts.metadata, M::None | M::Copyright) { + ui.checkbox(&mut opts.remove_location, crate::i18n::tr("Remove location info")); + } + let mut wm_on = opts.watermark.is_some(); + if ui.checkbox(&mut wm_on, crate::i18n::tr("Watermark")).changed() { + opts.watermark = wm_on.then(|| lightcraft_engine::export::Watermark { text: "© ".into(), ..Default::default() }); + } + let wm_shown = opts.watermark.clone(); + if let Some(wm) = &wm_shown { + watermark_preview(app, ui, wm, (!*full_size).then_some(*resize)); + } + if let Some(wm) = &mut opts.watermark { + // text, or a graphic (a logo with transparency) + let mut graphic = !wm.image.is_empty() || ui.data(|d| d.get_temp::(egui::Id::unique("wm-graphic"))).unwrap_or(false); + let before = graphic; + choices(ui, "Style", "exportWmStyle", &[(false, "Text"), (true, "Graphic")], &mut graphic); + if graphic != before { + ui.data_mut(|d| d.insert_temp(egui::Id::unique("wm-graphic"), graphic)); + if !graphic { + wm.image.clear(); + } + } + if graphic { + field(ui, "Graphic", |ui| { + trailing_button_row(ui, |ui| { + if app.services.pick_files.is_some() + && crate::widgets::text_button(ui, "exportWmChoose", crate::i18n::tr("Choose…"), false).clicked() + && let Some(f) = app.services.pick_files.as_mut().and_then(|pick| pick().into_iter().next()) + { + wm.image = f; + } + crate::widgets::named( + ui.add(egui::TextEdit::singleline(&mut wm.image).hint_text("logo.png").desired_width(ui.available_width())), + crate::i18n::tr("Image"), + ); + }); + }); + let mut width = wm.image_width as f64 * 100.0; + if num(ui, &WM_IMAGE_WIDTH, &mut width) { + wm.image_width = (width / 100.0) as f32; + } + } else { + choices( + ui, + app.ui.language.tr("Text direction"), + "exportWmOrientation", + &[(false, app.ui.language.tr("Horizontal text")), (true, app.ui.language.tr("Vertical text"))], + &mut wm.vertical, + ); + field(ui, "Text", |ui| { + crate::widgets::named( + ui.add( + egui::TextEdit::multiline(&mut wm.text) + .desired_rows(2) + .hint_text(crate::i18n::tr("© Your Name")) + .desired_width(f32::INFINITY), + ), + crate::i18n::tr("Text"), + ) + }); + } + choices(ui, "Position", "exportWmAnchor", &WM_POSITIONS, &mut wm.anchor); + choices(ui, "Rotate", "exportWmRotation", &[(0u16, "0°"), (90, "90°"), (180, "180°"), (270, "270°")], &mut wm.rotation); + let mut size = wm.size as f64 * 100.0; + if !graphic && num(ui, &WM_SIZE, &mut size) { + wm.size = (size / 100.0) as f32; + } + let mut op = wm.opacity as f64 * 100.0; + if num(ui, &WM_OPACITY, &mut op) { + wm.opacity = (op / 100.0) as f32; + } + // like Lightroom Classic: a margin from the side edges and one from the top and bottom + // edges, each unused (greyed) when the position is centred along that axis + use lightcraft_engine::export::Anchor as P; + let across = !matches!(wm.anchor, P::Top | P::Center | P::Bottom); + if let Some(v) = crate::widgets::slider(ui, &WM_INSET_X, wm.inset_x as f64 * 100.0, across, None).value { + wm.inset_x = (v / 100.0) as f32; + } + let down = !matches!(wm.anchor, P::Left | P::Center | P::Right); + if let Some(v) = crate::widgets::slider(ui, &WM_INSET_Y, wm.inset_y as f64 * 100.0, down, None).value { + wm.inset_y = (v / 100.0) as f32; + } + if !graphic { + ui.checkbox(&mut wm.shadow, crate::i18n::tr("Shadow")); + } + } + if opts.watermark != wm_shown { + // the preview above was drawn with the old settings + ui.ctx().request_repaint(); + } + } + ui.add_space(4.0); + if opts.format == F::Tiff { + use lightcraft_engine::export::TiffCompression as Z; + choices(ui, "Compression", "exportTiffCompression", &[(Z::None, "None"), (Z::Lzw, "LZW"), (Z::Deflate, "ZIP")], &mut opts.tiff_compression); + } + if opts.format == F::Dng { + use lightcraft_engine::export::DngCompression as Z; + choices( + ui, + "Compression", + "exportDngCompression", + &[(Z::Lossless, "Lossless"), (Z::Deflate, "ZIP"), (Z::Uncompressed, "None"), (Z::Lossy, "Lossy")], + &mut opts.dng_compression, + ); + } + let naming_id = egui::Id::unique("export-naming"); + let tags_open = field(ui, "File name", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let w = crate::import::tag_field_width(ui); + crate::widgets::named( + ui.add( + egui::TextEdit::singleline(&mut opts.naming) + .id(naming_id) + .hint_text("{name}-{seq} · {date} · {title} · {folder}") + .desired_width(w), + ), + crate::i18n::tr("File name"), + ); + crate::import::tag_toggle(ui, "exportNaming") + }); + if tags_open { + crate::import::tag_help(ui, "exportNaming", &mut opts.naming, naming_id); + } + crate::import::unknown_tags_warning(ui, &opts.naming); + if opts.naming.contains("{seq") { + let mut v = opts.start_number as f64; + if num(ui, &START_NUMBER, &mut v) { + opts.start_number = v as u32; + } + } + // like Lightroom Classic's Export To: one folder, or each photo's own folder (not on + // the web, where originals live in the browser's storage, not in folders) + use lightcraft_engine::export::DestinationMode as D; + if !cfg!(target_arch = "wasm32") || opts.destination_mode != D::SpecificFolder { + choices( + ui, + "Export to", + "exportDestinationMode", + &[(D::SpecificFolder, "Specific folder"), (D::SameAsOriginal, "Same folder as original photo")], + &mut opts.destination_mode, + ); + } + if opts.destination_mode == D::SpecificFolder { + field(ui, "Folder", |ui| { + trailing_button_row(ui, |ui| { + if app.services.pick_folder.is_some() + && crate::widgets::text_button(ui, "exportChooseFolder", crate::i18n::tr("Choose…"), false).clicked() + && let Some(pick) = app.services.pick_folder.as_mut() + && let Some(d) = pick() + { + *dir = d; + } + crate::widgets::named(ui.add(egui::TextEdit::singleline(dir).desired_width(ui.available_width())), crate::i18n::tr("Folder")); + }); + }); + } + field(ui, "Subfolder", |ui| { + crate::widgets::named( + ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), + crate::i18n::tr("Subfolder"), + ) + }); + use lightcraft_engine::export::Conflict as K; + choices(ui, "If file exists", "exportConflict", &[(K::Unique, "Add number"), (K::Overwrite, "Overwrite"), (K::Skip, "Skip")], &mut opts.conflict); + // After export: Apple Photos imports the files written (macOS only) + #[cfg(target_os = "macos")] + { + let r = ui.checkbox(&mut opts.add_to_photos, crate::i18n::tr("Add to Apple Photos")); + crate::widgets::register(ui, "exportAddToPhotos", r.rect); + if opts.add_to_photos { + field(ui, "Album", |ui| { + crate::widgets::named( + ui.add(egui::TextEdit::singleline(&mut opts.photos_album).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), + crate::i18n::tr("Album"), + ) + }); + ui.label( + egui::RichText::new(crate::i18n::tr("The first time, macOS asks whether LightCraft may control Photos.")) + .size(11.0) + .color(t.text_dim), + ); + } + } + // nowhere to add them: a setting carried over from a Mac is dropped, not refused + #[cfg(not(target_os = "macos"))] + { + opts.add_to_photos = false; + } + ui.add_space(4.0); + field(ui, "Save preset", |ui| { + trailing_button_row(ui, |ui| { + let named = !preset_name.trim().is_empty(); + if crate::widgets::text_button(ui, "exportSavePreset", crate::i18n::tr("Save"), false).clicked() && named { + let params = export_dialog_params(opts, *full_size, resize, *limit_kb); + match app.run("export.savePreset", json!({"name": preset_name.trim(), "params": params})) { + Ok(_) => { + app.toast(ui.ctx(), crate::i18n::tr_format!("Saved export preset “{}”", preset_name.trim())); + preset_name.clear(); + } + Err(e) => app.toast(ui.ctx(), e), + } + } + crate::widgets::named( + ui.add(egui::TextEdit::singleline(preset_name).hint_text(crate::i18n::tr("Preset name")).desired_width(ui.available_width())), + crate::i18n::tr("Preset name"), + ); + }); + }); +} + +/// The body of the KeywordTag dialog. +fn keyword_tag_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, confirm: &mut bool, dlg: &mut Dialog) { + let Dialog::KeywordTag { + editing, + name, + parent, + inside, + synonyms, + include_on_export, + export_containing, + export_synonyms, + person, + add_to_selected, + .. + } = dlg + else { + return; + }; + ui.label(egui::RichText::new(crate::i18n::tr("Keyword Name")).color(t.text_dim)); + // several fields: the name takes the focus when the dialog opens, not after + let r = crate::text_field::TextField::singleline("field:keywordTagName", name) + .hint(crate::i18n::tr("Keyword Name")) + .width(f32::INFINITY) + .select_on_focus(true) + .show(ui); + if ui.is_enabled() && just_opened(ui, "keyword-tag") { + r.response.request_focus(); + } + if r.ending == Some(crate::text_field::Ending::Return) { + *confirm = true; + } + ui.add_space(4.0); + ui.label(egui::RichText::new(crate::i18n::tr("Synonyms")).color(t.text_dim)); + // Return in either field creates (or saves) the keyword + let r = crate::text_field::TextField::singleline("field:keywordSynonyms", synonyms) + .hint(crate::i18n::tr("Separated by commas")) + .width(f32::INFINITY) + .show(ui); + if r.ending == Some(crate::text_field::Ending::Return) { + *confirm = true; + } + ui.add_space(6.0); + if editing.is_none() { + if let Some(p) = parent { + let r = ui.checkbox(inside, crate::i18n::tr_format!("Put inside “{parent}”", parent = p.replace('|', " › "))); + crate::widgets::register(ui, "check:keywordInside", r.rect); + } + let n = app.session.selection.ids.len(); + let r = ui.add_enabled(n > 0, egui::Checkbox::new(add_to_selected, crate::i18n::tr("Add to Selected Photos"))); + crate::widgets::register(ui, "check:keywordAddToSelected", r.rect); + ui.add_space(4.0); + } + ui.label(egui::RichText::new(crate::i18n::tr("Keyword Tag Options")).color(t.text_dim)); + for (on, label, id) in [ + (include_on_export, "Include on Export", "check:keywordIncludeOnExport"), + (export_containing, "Export Containing Keywords", "check:keywordExportContaining"), + (export_synonyms, "Export Synonyms", "check:keywordExportSynonyms"), + (person, "Person", "check:keywordPerson"), + ] { + let r = ui.checkbox(on, crate::i18n::tr(label)); + crate::widgets::register(ui, id, r.rect); + } +} + +/// The body of the KeywordSet dialog. +fn keyword_set_body(ui: &mut egui::Ui, t: &Tokens, confirm: &mut bool, dlg: &mut Dialog) { + let Dialog::KeywordSet { replaces, name, slots, as_new } = dlg else { return }; + ui.label(egui::RichText::new(crate::i18n::tr("Set name")).color(t.text_dim)); + let r = crate::text_field::TextField::singleline("field:keywordSetName", name) + .hint(crate::i18n::tr("Set name")) + .width(f32::INFINITY) + .select_on_focus(true) + .show(ui); + if ui.is_enabled() && just_opened(ui, "keyword-set") { + r.response.request_focus(); + } + let mut returned = r.ending == Some(crate::text_field::Ending::Return); + ui.add_space(6.0); + ui.label(egui::RichText::new(crate::i18n::tr("Keywords (⌥1–⌥9)")).color(t.text_dim)); + slots.resize(9, String::new()); + // equal columns of a set width (the window is still finding its own) + let w = 130.0; + egui::Grid::new("keyword-set-slots").num_columns(3).min_col_width(w).spacing([6.0, 4.0]).show(ui, |ui| { + for (i, slot) in slots.iter_mut().enumerate() { + let r = crate::text_field::TextField::singleline(&format!("field:keywordSetSlot:{}", i + 1), slot) + .hint(format!("⌥{}", i + 1)) + .width(w) + .show(ui); + returned |= r.ending == Some(crate::text_field::Ending::Return); + if i % 3 == 2 { + ui.end_row(); + } + } + }); + if replaces.is_some() { + ui.add_space(6.0); + let r = ui.checkbox(as_new, crate::i18n::tr("Save as a new set")); + crate::widgets::register(ui, "check:keywordSetAsNew", r.rect); + } + // Return in any field saves the set + if returned { + *confirm = true; + } +} + +/// The body of the MoveKeyword dialog. +fn move_keyword_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::MoveKeyword { keyword, parent } = dlg else { return }; + let name = keyword.rsplit('|').next().unwrap_or(keyword); + match parent { + Some(p) => { + ui.label(crate::i18n::tr_format!("“{parent}” has a “{name}” already. Merge the two?", parent = p.replace('|', " › "), name = name)) + } + None => ui.label(crate::i18n::tr_format!("There is a “{name}” at the top level already. Merge the two?", name = name)), + }; + ui.label(egui::RichText::new(crate::i18n::tr("Their photos and the keywords below them come together under one keyword.")).color(t.text_dim)); +} + +/// The body of the DeleteKeyword dialog. +fn delete_keyword_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::DeleteKeyword { keyword, count } = dlg else { return }; + let name = keyword.replace('|', " › "); + ui.label(crate::i18n::tr_format!("Delete the keyword “{name}”?", name = name)); + ui.label( + egui::RichText::new(if *count == 0 { + crate::i18n::tr("No photo has it; the keywords below it go too.").to_string() + } else { + crate::i18n::tr_format!( + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.", + if *count == 1 { "" } else { "s" }, + count = count + ) + }) + .color(t.text_dim), + ); +} + +/// The body of the DeleteEffects dialog. +fn delete_effects_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::DeleteEffects { names } = dlg else { return }; + let what = match names.as_slice() { + [one] => crate::i18n::tr_format!("Delete the effect preset “{name}”?", name = one), + _ => crate::i18n::tr_format!("Delete these {n} effect presets?", n = names.len()), + }; + ui.label(what); + ui.label(egui::RichText::new(crate::i18n::tr("Masks keep their sliders. Export presets first to keep a copy.")).color(t.text_dim)); +} + +/// The body of the MovePhotos dialog. +fn move_photos_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::MovePhotos { ids, name, dont_ask, .. } = dlg else { return }; + let n = ids.len(); + ui.label(crate::i18n::tr_format!("Move {n} photo{} to “{}”?", if n == 1 { "" } else { "s" }, name, n = n)); + ui.label(egui::RichText::new(crate::i18n::tr("Their files and XMP sidecars move on disk. Edit → Undo moves them back.")).color(t.text_dim)); + ui.add_space(4.0); + let c = ui.checkbox(dont_ask, crate::i18n::tr("Don't ask again")); + crate::widgets::register(ui, "check:moveDontAsk", c.rect); + c.on_hover_text(crate::i18n::tr("Settings → Confirm before moving photos to another folder on disk turns it back on")); +} + +/// The body of the LightroomCatalog dialog. +fn lightroom_catalog_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::LightroomCatalog { folder, catalog } = dlg else { return }; + ui.label(crate::i18n::tr("This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?")); + ui.label(egui::RichText::new(catalog.as_deref().unwrap_or(folder.as_str())).color(t.text_dim)); + // (the import then asks where the catalog goes, unless Settings says to merge, #2672) + let asks = app.ui.settings.lightroom_import == crate::state::LightroomImport::Ask && app.session.library.as_ref().is_some_and(|l| l.on_disk); + let what = if asks { + "The import reads the catalog without changing it. Next, choose whether its photos, edits, keywords and collections join the library open now or go into a new library." + } else { + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now." + }; + ui.label(egui::RichText::new(crate::i18n::tr(what)).color(t.text_dim)); +} + +/// The body of the RemoveFolder dialog. +fn remove_folder_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::RemoveFolder { name, count, path, disk } = dlg else { return }; + ui.label(crate::i18n::tr_format!( + "Remove “{name}” and its {count} photo{} from the library?", + if *count == 1 { "" } else { "s" }, + name = name, + count = count + )); + ui.label(egui::RichText::new(path.as_str()).color(t.text_dim)); + let scope = if *disk { "All the photos imported from this disk are included." } else { "That includes the photos in the folders inside it." }; + ui.label(egui::RichText::new(crate::i18n::tr(scope)).color(t.text_dim)); + ui.label( + egui::RichText::new(crate::i18n::tr("They move to Recently Deleted and can be restored; no file on disk is touched.")).color(t.text_dim), + ); +} + +/// The body of the About dialog. +fn about_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::About = dlg else { return }; + ui.set_min_width(680.0); + let tab_id = egui::Id::unique("about_tab"); + let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (i, (id, label)) in ABOUT_TABS.iter().enumerate() { + if crate::widgets::text_button(ui, &format!("aboutTab-{id}"), label, usize::from(tab) == i).clicked() { + tab = u8::try_from(i).unwrap_or(0); + } + } + }); + ui.data_mut(|d| d.insert_temp(tab_id, tab)); + ui.separator(); + match tab { + 1 => crate::credits::contributors_ui(app, ui), + 2 => crate::credits::models_ui(ui), + _ => { + ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text)); + ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION"))); + ui.label(crate::i18n::tr_format!( + "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", + crate::theme::font_credits(app.chinese_font.is_some()) + )); + ui.add_space(10.0); + let r = crate::button::Button::new("Join the ArtCraft Discord") + .primary() + .large() + .min_width(260.0) + .widget("button:aboutDiscord") + .tooltip(crate::links::DISCORD) + .show(ui); + if r.clicked() { + let _ = crate::links::open(app, crate::links::DISCORD); + } + ui.add_space(6.0); + for (label, url) in [ + ("LightCraft website", crate::links::APP_PAGE), + ("Source code on GitHub", crate::links::GITHUB), + ("ArtCraft — more creative apps", crate::links::WEBSITE), + ] { + let r = ui.link(crate::i18n::tr(label)).on_hover_text(url); + if r.clicked() { + let _ = crate::links::open(app, url); + } + } + } + } +} + +/// The body of the open dialog: what each kind of dialog shows between its title and its buttons. `confirm` is set +/// by a dialog that is confirmed from its body (a text field's Return). +fn dialog_content(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, body_max: f32, confirm: &mut bool, dlg: &mut Dialog) { + match dlg { + Dialog::AutoStack { .. } => auto_stack_body(app, ui, t, dlg), + Dialog::Cull { .. } => cull_body(app, ui, t, dlg), + Dialog::WhatsNew => whats_new_body(ui, t, dlg), + Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::model_dialog(app, ui, t, info, accepted), + Dialog::FaceModel { info, accepted, .. } => crate::panels::faces::model_dialog(app, ui, t, info, accepted), + Dialog::LightroomResult { .. } => lightroom_result_body(ui, dlg), + Dialog::SystemInfo { .. } => system_info_body(ui, t, dlg), + Dialog::AllMetadata { .. } => all_metadata_body(ui, t, dlg), + Dialog::SmartRules { .. } => smart_rules_body(app, ui, t, dlg), + Dialog::NewSmartAlbum { .. } => new_smart_album_body(app, ui, t, confirm, dlg), + Dialog::CaptureTime { .. } => capture_time_body(app, ui, t, dlg), + Dialog::LabelNames { .. } => label_names_body(app, ui, t, dlg), + Dialog::Rename { .. } => rename_body(app, ui, t, dlg), + Dialog::RenamePerson { .. } => rename_person_body(app, ui, t, confirm, dlg), + Dialog::RenameKeyword { .. } => rename_keyword_body(app, ui, t, confirm, dlg), + Dialog::MergeKeywords { .. } => merge_keywords_body(app, ui, t, confirm, dlg), + Dialog::TextPrompt { value, hint, .. } => { + if dialog_field(ui, "field:prompt", value, hint) { + *confirm = true; + } + } + Dialog::NewAlbum { .. } => new_album_body(app, ui, confirm, dlg), + Dialog::RenameAlbum { name, .. } => { + if dialog_field(ui, "field:albumName", name, "Name") { + *confirm = true; + } + } + Dialog::CreatePreset { name, group, groups } => { + crate::widgets::named( + ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Preset name")).desired_width(f32::INFINITY)), + crate::i18n::tr("Preset name"), + ); + crate::widgets::named( + ui.add(egui::TextEdit::singleline(group).hint_text(crate::i18n::tr("Group")).desired_width(f32::INFINITY)), + crate::i18n::tr("Group"), + ); + ui.label(egui::RichText::new(crate::i18n::tr("Settings to include")).color(t.text_dim)); + group_checklist(ui, "presetInclude", groups); + } + Dialog::CopySettings { groups } => group_checklist(ui, "copyGroup", groups), + Dialog::PasteSettings { .. } => paste_settings_body(app, ui, t, dlg), + Dialog::SlideshowExport { settings } => slideshow_export_body(app, ui, settings), + Dialog::ContactSheet { .. } => contact_sheet_body(app, ui, dlg), + Dialog::Export { .. } => export_body(app, ui, t, dlg), + Dialog::Merge { opts } => crate::merge::body(app, ui, opts), + Dialog::Import { opts } => crate::import::body(app, ui, opts), + // (Settings is never taller than `body_max`, so the scroll area round it never + // scrolls: its tab bar stays put and its pages scroll by themselves) + Dialog::Settings { tab } => crate::panels::settings::window(app, ui, tab, body_max), + Dialog::SamModel { error, .. } => sam_model_body(app, ui, error.as_deref()), + Dialog::KeywordTag { .. } => keyword_tag_body(app, ui, t, confirm, dlg), + Dialog::KeywordSet { .. } => keyword_set_body(ui, t, confirm, dlg), + Dialog::MoveKeyword { .. } => move_keyword_body(ui, t, dlg), + Dialog::DeleteKeyword { .. } => delete_keyword_body(ui, t, dlg), + Dialog::SaveEffect { name, group, fields, amount, brush } => { + if crate::panels::mask_effects::save_body(app, ui, name, group, fields, amount, brush) { + *confirm = true; + } + } + Dialog::ManageEffects { search, selected, rename, confirm_delete, new_group } => { + crate::panels::mask_effects::manage_body(app, ui, search, selected, rename, confirm_delete, new_group) + } + Dialog::DeleteEffects { .. } => delete_effects_body(ui, t, dlg), + Dialog::ConfirmDelete { count } => { + let what = + if *count == 1 { crate::i18n::tr("this photo").to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) }; + ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what)); + ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim)); + } + Dialog::MovePhotos { .. } => move_photos_body(ui, t, dlg), + d @ Dialog::SynchronizeFolder { .. } => crate::sync::body(app, ui, d), + Dialog::LightroomCatalog { .. } => lightroom_catalog_body(app, ui, t, dlg), + d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::body(app, ui, d), + Dialog::RemoveFolder { .. } => remove_folder_body(ui, t, dlg), + Dialog::About => about_body(app, ui, t, dlg), + Dialog::Shortcuts => crate::panels::keymap::body(app, ui, t), + Dialog::ViewOptions => crate::panels::settings::view_options(app, ui, t), + Dialog::Print => crate::print_view::body(app, ui, t), + } +} From 8c3dd7ebd2170e51366eaf2b816c6fb26c7b5f0a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 20:28:52 +0000 Subject: [PATCH 30/39] refactor(ui-egui): split dialog frame into title, buttons and confirm --- crates/ui-egui/src/dialogs.rs | 605 +++++++++++++++++++--------------- 1 file changed, 334 insertions(+), 271 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1e5ae5e2d..4278404ff 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -109,7 +109,127 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { { confirm = true; } - let title: String = match &dlg { + let title = dialog_title(app, &dlg); + let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(16, 12)); + // The import review can be resized (its photo grid takes the room); it keeps a window id of its + // own so the size it is given doesn't carry over to the other dialogs. Its content scrolls + // rather than growing the window when the options below the grid get taller (e.g. Copy). + let import = matches!(dlg, Dialog::Import { .. }); + // The Print dialog sizes itself to the window and scrolls its settings column on its own. + let print = matches!(dlg, Dialog::Print); + let window_id = window_id(&dlg); + // Every other dialog fits the window (#781): its options scroll when they don't, and the title + // bar and the button row stay outside the scroll area. The room they take is measured on the + // previous frame (a guess on the first one). + let chrome_id = window_id.with("chrome"); + let chrome = ctx.data(|d| d.get_temp::(chrome_id)).unwrap_or(DIALOG_CHROME_GUESS); + // Settings keeps its top edge, and the widest it has been, while tabs of different sizes are shown, so its tab + // row stays where it is (centred like the others, the row jumped with each tab's height) + let settings = matches!(dlg, Dialog::Settings { .. }); + let top = (screen.height() * 0.08).clamp(DIALOG_SCREEN_MARGIN, 96.0); + let body_max = + if settings { screen.height() - top - DIALOG_SCREEN_MARGIN - chrome } else { screen.height() - 2.0 * DIALOG_SCREEN_MARGIN - chrome } + .max(DIALOG_MIN_BODY); + let widest_id = window_id.with("widest"); + let widest = if settings { ctx.data(|d| d.get_temp::(widest_id)).unwrap_or(0.0).min(screen.width() * 0.9) } else { 0.0 }; + let mut content_w = 0.0f32; + let mut body_h = None; + // before the window: drawing it closes a dropdown the click lands outside of + let popup_was_open = egui::Popup::is_any_open(ctx); + let shown = egui::Window::new(crate::i18n::tr(&title)) + .id(window_id) + .collapsible(false) + .resizable(import) + .vscroll(import) + .resize(|r| if import { r.default_height(crate::import::DIALOG_SIZE[1]) } else { r }) + .frame(frame) + .anchor(if settings { egui::Align2::CENTER_TOP } else { egui::Align2::CENTER_CENTER }, [0.0, if settings { top } else { 0.0 }]) + .min_width(widest) + .default_width(match dlg { + Dialog::Import { .. } => crate::import::DIALOG_SIZE[0], + Dialog::SmartRules { .. } => 680.0, + Dialog::AllMetadata { .. } => 620.0, + Dialog::Print => 1100.0, + Dialog::FaceModel { .. } => 460.0, + Dialog::LightroomCatalog { .. } => 440.0, + Dialog::LightroomDestination { .. } => 480.0, + Dialog::ManageEffects { .. } => 560.0, + _ => 380.0, + }) + .show(ctx, |ui| { + // Disabled, not skipped: the dialog keeps its layout and state, but a raw event in the + // first prompt frame cannot toggle a checkbox or press a button underneath it. The + // blocking library window gets the same treatment: it owns input while it is on screen, + // so the dialog behind it must not take focus or accept typed text either. + if raw_blocked { + ui.disable(); + } + ui.spacing_mut().item_spacing.y = 8.0; + body_h = dialog_body(ui, (!import && !print).then_some(title.as_str()), body_max, |ui| { + dialog_content(app, ui, &t, body_max, &mut confirm, &mut dlg) + }); + ui.add_space(4.0); + // the confirm on the right with Cancel to its left (macOS and GNOME's order), filled with + // the accent when it is the dialog's primary action. The row spans the width the window + // gives its content this frame (its default width, or its content's if wider), so it + // reaches the edge without widening the window. + let row_w = ui.available_width().max(ui.min_rect().width()).max(1.0); + let row_h = ui.spacing().interact_size.y; + ui.allocate_ui_with_layout(egui::vec2(row_w, row_h), egui::Layout::left_to_right(egui::Align::Center), |ui| { + button_row(app, ui, &dlg, &mut close, &mut confirm); + }); + if import { + crate::import::note_dialog_bottom(ui); + } + content_w = ui.min_rect().width(); + }); + if settings && content_w > widest + 0.5 { + ctx.data_mut(|d| d.insert_temp(widest_id, content_w)); + } + if let Some(w) = &shown { + // (the import review keeps its hand-picked photos however the click lands) + if !raw_blocked && app.ui.settings.close_dialogs_outside && !import && clicked_backdrop(ctx, w.response.rect, popup_was_open) { + close = true; + } else if raw_blocked { + // A press made under the prompt or library window must not be remembered as a + // backdrop press for a release that arrives after that covering window goes away. + ctx.data_mut(|d| d.remove_temp::(egui::Id::unique("dialog-backdrop-press"))); + } + } + if let Some(w) = shown { + ctx.memory_mut(|m| m.set_modal_layer(w.response.layer_id)); + if let Some(body_h) = body_h { + let measured = (w.response.rect.height() - body_h).max(0.0); + if (measured - chrome).abs() > 0.5 { + ctx.data_mut(|d| d.insert_temp(chrome_id, measured)); + ctx.request_repaint(); + } + } + ctx.move_to_top(w.response.layer_id); + crate::widgets::register_response("dialog:window", w.response.rect, &w.response); + } + // (while the shortcuts editor records a key, it takes Esc itself to cancel; an open popup in + // the dialog, such as a date picker's calendar, takes it to close itself) + if !raw_blocked && ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !crate::widgets::escape_taken(ctx) { + close = true; + } + if !raw_blocked && confirm { + close |= confirmed(app, ctx, &mut dlg); + } + // a dialog opened from inside this one (a button in Settings that shows a licence) takes its place + if app.ui.dialog.as_ref().is_some_and(|now| *now != at_start) { + return; + } + if !close && !blocked { + let frame = ctx.cumulative_frame_nr(); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(BACKDROP_SHOWN), frame)); + } + app.ui.dialog = if close { None } else { Some(dlg) }; +} + +/// The title of a dialog. +fn dialog_title(app: &LightcraftApp, dlg: &Dialog) -> String { + match dlg { Dialog::TextPrompt { title, .. } => title.as_str(), Dialog::NewAlbum { folder: true, .. } => "Create Folder", Dialog::NewAlbum { .. } => "Create Album", @@ -165,284 +285,227 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { Dialog::Shortcuts => "Keyboard Shortcuts", Dialog::ViewOptions => "View Options", } - .to_string(); - let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(16, 12)); - // The import review can be resized (its photo grid takes the room); it keeps a window id of its - // own so the size it is given doesn't carry over to the other dialogs. Its content scrolls - // rather than growing the window when the options below the grid get taller (e.g. Copy). - let import = matches!(dlg, Dialog::Import { .. }); - // The Print dialog sizes itself to the window and scrolls its settings column on its own. - let print = matches!(dlg, Dialog::Print); - let window_id = window_id(&dlg); - // Every other dialog fits the window (#781): its options scroll when they don't, and the title - // bar and the button row stay outside the scroll area. The room they take is measured on the - // previous frame (a guess on the first one). - let chrome_id = window_id.with("chrome"); - let chrome = ctx.data(|d| d.get_temp::(chrome_id)).unwrap_or(DIALOG_CHROME_GUESS); - // Settings keeps its top edge, and the widest it has been, while tabs of different sizes are shown, so its tab - // row stays where it is (centred like the others, the row jumped with each tab's height) - let settings = matches!(dlg, Dialog::Settings { .. }); - let top = (screen.height() * 0.08).clamp(DIALOG_SCREEN_MARGIN, 96.0); - let body_max = - if settings { screen.height() - top - DIALOG_SCREEN_MARGIN - chrome } else { screen.height() - 2.0 * DIALOG_SCREEN_MARGIN - chrome } - .max(DIALOG_MIN_BODY); - let widest_id = window_id.with("widest"); - let widest = if settings { ctx.data(|d| d.get_temp::(widest_id)).unwrap_or(0.0).min(screen.width() * 0.9) } else { 0.0 }; - let mut content_w = 0.0f32; - let mut body_h = None; - // before the window: drawing it closes a dropdown the click lands outside of - let popup_was_open = egui::Popup::is_any_open(ctx); - let shown = egui::Window::new(crate::i18n::tr(&title)).id(window_id) - .collapsible(false) - .resizable(import) - .vscroll(import) - .resize(|r| if import { r.default_height(crate::import::DIALOG_SIZE[1]) } else { r }) - .frame(frame) - .anchor(if settings { egui::Align2::CENTER_TOP } else { egui::Align2::CENTER_CENTER }, [0.0, if settings { top } else { 0.0 }]) - .min_width(widest) - .default_width(match dlg { - Dialog::Import { .. } => crate::import::DIALOG_SIZE[0], - Dialog::SmartRules { .. } => 680.0, - Dialog::AllMetadata { .. } => 620.0, - Dialog::Print => 1100.0, - Dialog::FaceModel { .. } => 460.0, - Dialog::LightroomCatalog { .. } => 440.0, - Dialog::LightroomDestination { .. } => 480.0, - Dialog::ManageEffects { .. } => 560.0, - _ => 380.0, - }) - .show(ctx, |ui| { - // Disabled, not skipped: the dialog keeps its layout and state, but a raw event in the - // first prompt frame cannot toggle a checkbox or press a button underneath it. The - // blocking library window gets the same treatment: it owns input while it is on screen, - // so the dialog behind it must not take focus or accept typed text either. - if raw_blocked { - ui.disable(); - } - ui.spacing_mut().item_spacing.y = 8.0; - body_h = dialog_body(ui, (!import && !print).then_some(title.as_str()), body_max, |ui| dialog_content(app, ui, &t, body_max, &mut confirm, &mut dlg)); - ui.add_space(4.0); - // the confirm on the right with Cancel to its left (macOS and GNOME's order), filled with - // the accent when it is the dialog's primary action. The row spans the width the window - // gives its content this frame (its default width, or its content's if wider), so it - // reaches the edge without widening the window. - let row_w = ui.available_width().max(ui.min_rect().width()).max(1.0); - let row_h = ui.spacing().interact_size.y; - ui.allocate_ui_with_layout(egui::vec2(row_w, row_h), egui::Layout::left_to_right(egui::Align::Center), |ui| { - // a model file LightCraft cannot use has nothing to confirm, nor has a dialog that - // only shows something (one Close, no Cancel / OK) - let unusable_model = matches!(&dlg, Dialog::DenoiseModel { info, .. } | Dialog::FaceModel { info, .. } if info["kind"] == "unsupported"); - let informational = unusable_model || matches!(dlg, Dialog::LightroomResult { first: None, .. }) || only_shows(&dlg); - let sam = &app.session.segmenter; - let (sam_installed, sam_running, sam_failed) = (sam.installed(), sam.download_status().running, sam.download_status().error.is_some()); - // no download location in this build: nothing to offer but the manual install - let sam_nowhere = sam_by_hand(sam); - let cancel = match &dlg { - Dialog::SamModel { .. } if sam_running || sam_installed || sam_nowhere => "Close", - Dialog::SamModel { .. } => "Not Now", - Dialog::LightroomResult { .. } => "Close", - _ => "Cancel", - }; - let add_label; - let ok = match &dlg { - Dialog::Import { opts } => { - let n = opts.selected_paths().len(); - let verb = if opts.copy && opts.move_files { "Move" } else { "Import" }; - add_label = crate::i18n::tr_format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }, verb = crate::i18n::tr(verb), n = n); - add_label.as_str() - } - Dialog::LightroomResult { first: Some(_), .. } => "Show in Library", - Dialog::ContactSheet { .. } => "Choose PDF…", - Dialog::SlideshowExport { .. } => "Export…", - Dialog::Print => "Print to PDF…", - Dialog::Merge { .. } => "Merge", - Dialog::DenoiseModel { info, .. } if info["download"].is_string() => "Accept & Download", - Dialog::DenoiseModel { .. } => "Install", - Dialog::FaceModel { info, .. } if !informational && info["download"].is_string() => "Download", - Dialog::FaceModel { .. } if !informational => "Install", - Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } => "Delete", - Dialog::SaveEffect { .. } => "Create", - Dialog::MovePhotos { .. } => "Move", - // (not "Merge": that is photo merging, HDR / panorama, in some languages) - Dialog::MoveKeyword { .. } => "Merge Keywords", - Dialog::KeywordSet { .. } => "Save", - Dialog::KeywordTag { editing: None, .. } => "Create", - Dialog::KeywordTag { .. } => "Save", - Dialog::RemoveFolder { .. } => "Remove", - Dialog::LightroomCatalog { .. } => "Import Lightroom Catalog…", - d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::ok_label(d), - Dialog::SynchronizeFolder { .. } if informational => "Close", - Dialog::SynchronizeFolder { .. } => "Synchronize", - Dialog::SamModel { then: Some(_), .. } if sam_installed => "Continue", - Dialog::SamModel { .. } if sam_installed || sam_running || sam_nowhere => "", - Dialog::SamModel { error, .. } if error.is_some() || sam_failed => "Try Again", - Dialog::SamModel { .. } => "Download", - Dialog::RenamePerson { from, to, .. } if person_rename_plan(&app.session.catalog, from, to).needs_merge => "Merge", - _ if informational => "Close", - _ => "OK", - }; - // installing waits for the licence to be accepted - // (and Synchronize waits for the scan) - let rename_blocked = match &dlg { - Dialog::RenamePerson { from, to, merge } => { - let plan = person_rename_plan(&app.session.catalog, from, to); - plan.block.is_some() || (plan.needs_merge && !*merge) - } - _ => false, - }; - let can_confirm = !rename_blocked - && (informational - || !matches!( - &dlg, - Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. } | Dialog::SynchronizeFolder { counts: None, .. } - ) - // smart-album rules that can't mean anything wait to be fixed - && !matches!(&dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty())); - let primary = !informational && can_confirm && confirm_style(&dlg) == ConfirmStyle::Primary; - use crate::button::{Button, Kind}; - // (each kind of dialog its own buttons: the keyboard's place doesn't carry from a - // dialog to the one it opens) - let kind = std::mem::discriminant(&dlg); - let cancel_button = Button::new(cancel).widget("button:dialogCancel").id_salt(kind).min_width(72.0); - let ok_button = Button::new(ok) - .kind(if primary { Kind::Primary } else { Kind::Secondary }) - .enabled(can_confirm) - .widget("button:dialogOk") - .id_salt(kind) - .min_width(72.0); - // (Cancel first, then the confirm: the keyboard goes through them as the eye does) - let gap = ui.spacing().item_spacing.x; - let mut buttons_w = 0.0; - if !informational { - buttons_w += cancel_button.desired_size(ui).x + gap; - } - if !ok.is_empty() { - buttons_w += ok_button.desired_size(ui).x; - } - ui.add_space((ui.available_width() - buttons_w).max(0.0)); - if !informational && cancel_button.show(ui).clicked() { - close = true; - } - let r = (!ok.is_empty()).then(|| ok_button.show(ui)); - if r.is_some_and(|r| r.clicked()) { - if informational { - close = true; - } else { - confirm = true; - } - } - }); - if import { - crate::import::note_dialog_bottom(ui); - } - content_w = ui.min_rect().width(); - }); - if settings && content_w > widest + 0.5 { - ctx.data_mut(|d| d.insert_temp(widest_id, content_w)); - } - if let Some(w) = &shown { - // (the import review keeps its hand-picked photos however the click lands) - if !raw_blocked && app.ui.settings.close_dialogs_outside && !import && clicked_backdrop(ctx, w.response.rect, popup_was_open) { - close = true; - } else if raw_blocked { - // A press made under the prompt or library window must not be remembered as a - // backdrop press for a release that arrives after that covering window goes away. - ctx.data_mut(|d| d.remove_temp::(egui::Id::unique("dialog-backdrop-press"))); + .to_string() +} + +/// Whether a dialog only shows something: a model file LightCraft cannot use has nothing to confirm, nor has a +/// dialog that only shows something (one Close, no Cancel / OK). +fn is_informational(dlg: &Dialog) -> bool { + let unusable_model = matches!(dlg, Dialog::DenoiseModel { info, .. } | Dialog::FaceModel { info, .. } if info["kind"] == "unsupported"); + unusable_model || matches!(dlg, Dialog::LightroomResult { first: None, .. }) || only_shows(dlg) +} + +/// What the SAM 3 download looks like: installed, running, failed, and whether there is nowhere to download from. +struct SamState { + installed: bool, + running: bool, + failed: bool, + /// no download location in this build: nothing to offer but the manual install + by_hand: bool, +} + +impl SamState { + fn of(app: &LightcraftApp) -> Self { + let sam = &app.session.segmenter; + SamState { + installed: sam.installed(), + running: sam.download_status().running, + failed: sam.download_status().error.is_some(), + by_hand: sam_by_hand(sam), } } - if let Some(w) = shown { - ctx.memory_mut(|m| m.set_modal_layer(w.response.layer_id)); - if let Some(body_h) = body_h { - let measured = (w.response.rect.height() - body_h).max(0.0); - if (measured - chrome).abs() > 0.5 { - ctx.data_mut(|d| d.insert_temp(chrome_id, measured)); - ctx.request_repaint(); - } +} + +/// The label of a dialog's cancel button. +fn cancel_label(dlg: &Dialog, sam: &SamState) -> &'static str { + match dlg { + Dialog::SamModel { .. } if sam.running || sam.installed || sam.by_hand => "Close", + Dialog::SamModel { .. } => "Not Now", + Dialog::LightroomResult { .. } => "Close", + _ => "Cancel", + } +} + +/// The label of a dialog's confirm button (empty: it has none). +fn ok_label(app: &LightcraftApp, dlg: &Dialog, informational: bool, sam: &SamState) -> String { + let label = match dlg { + Dialog::Import { opts } => { + let n = opts.selected_paths().len(); + let verb = if opts.copy && opts.move_files { "Move" } else { "Import" }; + return crate::i18n::tr_format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }, verb = crate::i18n::tr(verb), n = n); } - ctx.move_to_top(w.response.layer_id); - crate::widgets::register_response("dialog:window", w.response.rect, &w.response); + Dialog::LightroomResult { first: Some(_), .. } => "Show in Library", + Dialog::ContactSheet { .. } => "Choose PDF…", + Dialog::SlideshowExport { .. } => "Export…", + Dialog::Print => "Print to PDF…", + Dialog::Merge { .. } => "Merge", + Dialog::DenoiseModel { info, .. } if info["download"].is_string() => "Accept & Download", + Dialog::DenoiseModel { .. } => "Install", + Dialog::FaceModel { info, .. } if !informational && info["download"].is_string() => "Download", + Dialog::FaceModel { .. } if !informational => "Install", + Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } => "Delete", + Dialog::SaveEffect { .. } => "Create", + Dialog::MovePhotos { .. } => "Move", + // (not "Merge": that is photo merging, HDR / panorama, in some languages) + Dialog::MoveKeyword { .. } => "Merge Keywords", + Dialog::KeywordSet { .. } => "Save", + Dialog::KeywordTag { editing: None, .. } => "Create", + Dialog::KeywordTag { .. } => "Save", + Dialog::RemoveFolder { .. } => "Remove", + Dialog::LightroomCatalog { .. } => "Import Lightroom Catalog…", + d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::ok_label(d), + Dialog::SynchronizeFolder { .. } if informational => "Close", + Dialog::SynchronizeFolder { .. } => "Synchronize", + Dialog::SamModel { .. } => return sam_ok_label(dlg, sam).to_string(), + Dialog::RenamePerson { from, to, .. } if person_rename_plan(&app.session.catalog, from, to).needs_merge => "Merge", + _ if informational => "Close", + _ => "OK", + }; + label.to_string() +} + +/// The label of the SAM 3 dialog's confirm button. +fn sam_ok_label(dlg: &Dialog, sam: &SamState) -> &'static str { + match dlg { + Dialog::SamModel { then: Some(_), .. } if sam.installed => "Continue", + Dialog::SamModel { .. } if sam.installed || sam.running || sam.by_hand => "", + Dialog::SamModel { error, .. } if error.is_some() || sam.failed => "Try Again", + _ => "Download", } - // (while the shortcuts editor records a key, it takes Esc itself to cancel; an open popup in - // the dialog, such as a date picker's calendar, takes it to close itself) - if !raw_blocked && ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !crate::widgets::escape_taken(ctx) { - close = true; +} + +/// Whether the confirm button works: installing waits for the licence to be accepted (and Synchronize waits for the +/// scan), a rename waits for its conflicts to be settled, smart-album rules that can't mean anything wait to be fixed. +fn can_confirm(app: &LightcraftApp, dlg: &Dialog, informational: bool) -> bool { + let rename_blocked = match dlg { + Dialog::RenamePerson { from, to, merge } => { + let plan = person_rename_plan(&app.session.catalog, from, to); + plan.block.is_some() || (plan.needs_merge && !*merge) + } + _ => false, + }; + let waiting = matches!( + dlg, + Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. } | Dialog::SynchronizeFolder { counts: None, .. } + ); + let bad_rules = + matches!(dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty()); + !rename_blocked && (informational || (!waiting && !bad_rules)) +} + +/// The row of buttons under a dialog: Cancel and the confirm; `close` and `confirm` are set when one is clicked. +fn button_row(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &Dialog, close: &mut bool, confirm: &mut bool) { + let informational = is_informational(dlg); + let sam = SamState::of(app); + let cancel = cancel_label(dlg, &sam); + let ok = ok_label(app, dlg, informational, &sam); + let ok = ok.as_str(); + let can_confirm = can_confirm(app, dlg, informational); + let primary = !informational && can_confirm && confirm_style(dlg) == ConfirmStyle::Primary; + use crate::button::{Button, Kind}; + // (each kind of dialog its own buttons: the keyboard's place doesn't carry from a + // dialog to the one it opens) + let kind = std::mem::discriminant(dlg); + let cancel_button = Button::new(cancel).widget("button:dialogCancel").id_salt(kind).min_width(72.0); + let ok_button = Button::new(ok) + .kind(if primary { Kind::Primary } else { Kind::Secondary }) + .enabled(can_confirm) + .widget("button:dialogOk") + .id_salt(kind) + .min_width(72.0); + // (Cancel first, then the confirm: the keyboard goes through them as the eye does) + let gap = ui.spacing().item_spacing.x; + let mut buttons_w = 0.0; + if !informational { + buttons_w += cancel_button.desired_size(ui).x + gap; } - if !raw_blocked && confirm { - match confirm_dialog(app, &dlg) { - // the import review stays open on an error (e.g. an unusable folder template), and so - // does Export (e.g. no folder chosen) so the choices aren't lost, and Edit Capture Time - // (a time that isn't a date, or a shift it refuses) - Err(e) - if matches!( - dlg, - Dialog::Import { .. } - | Dialog::Export { .. } - | Dialog::ContactSheet { .. } - | Dialog::SlideshowExport { .. } - | Dialog::Print - | Dialog::NewAlbum { .. } - | Dialog::NewSmartAlbum { .. } - | Dialog::SmartRules { .. } - | Dialog::KeywordTag { .. } - | Dialog::KeywordSet { .. } - | Dialog::SynchronizeFolder { .. } - | Dialog::SaveEffect { .. } - | Dialog::CaptureTime { .. } - ) => - { - app.toast(ctx, e) - } - // the SAM 3 dialog stays open to show the download (or why it can't start) - Err(e) if matches!(dlg, Dialog::SamModel { .. }) => { - if let Dialog::SamModel { error, .. } = &mut dlg { - *error = Some(e); - } - } - Ok(r) if matches!(dlg, Dialog::NewAlbum { .. }) && r.get("skipped").and_then(serde_json::Value::as_u64).is_some_and(|n| n > 0) => { - // albums hold library photos (issue #684): say which part of the selection stayed out - crate::panels::grid::toast_skipped(app, ctx, &r); - close = true; - } - // a move says how it went, or why nothing moved - Ok(r) if matches!(dlg, Dialog::MovePhotos { .. }) => { - if let Dialog::MovePhotos { name, .. } = &dlg { - let text = crate::panels::left::moved_text(r["moved"].as_u64().unwrap_or(0), name); - app.toast(ctx, text); - } - close = true; - } - Err(e) if matches!(dlg, Dialog::MovePhotos { .. }) => { - app.toast(ctx, e); - close = true; - } - // photos whose capture time doesn't read are left as they were: say so - Ok(r) if matches!(dlg, Dialog::CaptureTime { .. }) && r["unreadable"].as_u64().is_some_and(|n| n > 0) => { - app.toast(ctx, crate::i18n::tr("Photos whose capture time can't be read were left as they were")); - close = true; - } - // where a Lightroom catalog goes: stays open to be corrected (a folder that isn't - // empty), unless the library it names has gone - Err(e) if matches!(dlg, Dialog::LightroomDestination { .. }) => { - close = !crate::lightroom_destination::keeps_open_on_error(app, &dlg); - app.toast(ctx, e); - } - Ok(_) if keeps_open(app, &dlg) => { - if let Dialog::SamModel { error, .. } = &mut dlg { - *error = None; - } - } - _ => close = true, + if !ok.is_empty() { + buttons_w += ok_button.desired_size(ui).x; + } + ui.add_space((ui.available_width() - buttons_w).max(0.0)); + if !informational && cancel_button.show(ui).clicked() { + *close = true; + } + let r = (!ok.is_empty()).then(|| ok_button.show(ui)); + if r.is_some_and(|r| r.clicked()) { + if informational { + *close = true; + } else { + *confirm = true; } } - // a dialog opened from inside this one (a button in Settings that shows a licence) takes its place - if app.ui.dialog.as_ref().is_some_and(|now| *now != at_start) { - return; +} + +/// The dialog's confirm was pressed: do it. Whether the dialog closes. +fn confirmed(app: &mut LightcraftApp, ctx: &egui::Context, dlg: &mut Dialog) -> bool { + let mut closed = false; + match confirm_dialog(app, dlg) { + // the import review stays open on an error (e.g. an unusable folder template), and so + // does Export (e.g. no folder chosen) so the choices aren't lost, and Edit Capture Time + // (a time that isn't a date, or a shift it refuses) + Err(e) + if matches!( + dlg, + Dialog::Import { .. } + | Dialog::Export { .. } + | Dialog::ContactSheet { .. } + | Dialog::SlideshowExport { .. } + | Dialog::Print + | Dialog::NewAlbum { .. } + | Dialog::NewSmartAlbum { .. } + | Dialog::SmartRules { .. } + | Dialog::KeywordTag { .. } + | Dialog::KeywordSet { .. } + | Dialog::SynchronizeFolder { .. } + | Dialog::SaveEffect { .. } + | Dialog::CaptureTime { .. } + ) => + { + app.toast(ctx, e) + } + // the SAM 3 dialog stays open to show the download (or why it can't start) + Err(e) if matches!(dlg, Dialog::SamModel { .. }) => { + if let Dialog::SamModel { error, .. } = dlg { + *error = Some(e); + } + } + Ok(r) if matches!(dlg, Dialog::NewAlbum { .. }) && r.get("skipped").and_then(serde_json::Value::as_u64).is_some_and(|n| n > 0) => { + // albums hold library photos (issue #684): say which part of the selection stayed out + crate::panels::grid::toast_skipped(app, ctx, &r); + closed = true; + } + // a move says how it went, or why nothing moved + Ok(r) if matches!(dlg, Dialog::MovePhotos { .. }) => { + if let Dialog::MovePhotos { name, .. } = dlg { + let text = crate::panels::left::moved_text(r["moved"].as_u64().unwrap_or(0), name); + app.toast(ctx, text); + } + closed = true; + } + Err(e) if matches!(dlg, Dialog::MovePhotos { .. }) => { + app.toast(ctx, e); + closed = true; + } + // photos whose capture time doesn't read are left as they were: say so + Ok(r) if matches!(dlg, Dialog::CaptureTime { .. }) && r["unreadable"].as_u64().is_some_and(|n| n > 0) => { + app.toast(ctx, crate::i18n::tr("Photos whose capture time can't be read were left as they were")); + closed = true; + } + // where a Lightroom catalog goes: stays open to be corrected (a folder that isn't + // empty), unless the library it names has gone + Err(e) if matches!(dlg, Dialog::LightroomDestination { .. }) => { + closed = !crate::lightroom_destination::keeps_open_on_error(app, dlg); + app.toast(ctx, e); + } + Ok(_) if keeps_open(app, dlg) => { + if let Dialog::SamModel { error, .. } = dlg { + *error = None; + } + } + _ => closed = true, } - if !close && !blocked { - let frame = ctx.cumulative_frame_nr(); - ctx.data_mut(|d| d.insert_temp(egui::Id::unique(BACKDROP_SHOWN), frame)); - } - app.ui.dialog = if close { None } else { Some(dlg) }; + + closed } /// The last frame a dialog was drawn and stayed open, for [`clicked_backdrop`]. From b74612513c36b640bcc8a15acd7feaabdacfaeb4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 20:32:45 +0000 Subject: [PATCH 31/39] refactor(ui-egui): split the Export dialog body into sections --- crates/ui-egui/src/dialogs.rs | 332 ++++++++++++++++++++-------------- 1 file changed, 198 insertions(+), 134 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4278404ff..bca04fe0c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6,6 +6,7 @@ use serde_json::json; use crate::LightcraftApp; use crate::state::Dialog; use crate::theme::Tokens; +use lightcraft_engine::export::{ExportOptions, Resize}; mod confirm; mod cull; @@ -1079,9 +1080,33 @@ fn contact_sheet_body(app: &mut LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dial /// The body of the Export dialog. fn export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { let Dialog::Export { opts, full_size, resize, preset_name, limit_kb, dir } = dlg else { return }; - use lightcraft_engine::export::{ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S}; let n = app.session.selection.ids.len().max(1); ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); + export_preset_row(app, ui, opts, full_size, resize, limit_kb); + ui.add_space(4.0); + export_format_rows(ui, t, opts); + export_color_rows(ui, t, opts); + export_quality_rows(ui, opts, limit_kb); + if opts.format.is_rendered() { + export_size(ui, full_size, resize, &mut opts.ppi); + export_output_rows(app, ui, opts, *full_size, *resize); + } + ui.add_space(4.0); + export_naming_rows(ui, opts); + export_destination_rows(app, ui, t, opts, dir); + ui.add_space(4.0); + export_save_preset_row(app, ui, opts, *full_size, resize, *limit_kb, preset_name); +} + +/// Export dialog: load a built-in or saved set of options. +fn export_preset_row( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + opts: &mut ExportOptions, + full_size: &mut bool, + resize: &mut Resize, + limit_kb: &mut u32, +) { // Preset: load a built-in or saved set of options into the dialog field(ui, "Preset", |ui| { let mut chosen = None; @@ -1109,7 +1134,11 @@ fn export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut *opts = o; } }); - ui.add_space(4.0); +} + +/// Export dialog: the format, its variants and the bit depth. +fn export_format_rows(ui: &mut egui::Ui, t: &Tokens, opts: &mut ExportOptions) { + use lightcraft_engine::export::ExportFormat as F; let before = opts.format; // "Rendered DNG" is a DNG option (below), not a format of its own in this list let mut shown = if opts.format == F::RenderedDng { F::Dng } else { opts.format }; @@ -1155,6 +1184,12 @@ fn export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut choices(ui, "Bit depth", "exportBitDepth", depths, &mut bd); opts.bit_depth = Some(bd); } +} + +/// Export dialog: HDR output and the color space it implies (or the choice of one). +fn export_color_rows(ui: &mut egui::Ui, t: &Tokens, opts: &mut ExportOptions) { + use lightcraft_engine::export::ExportFormat as F; + let rendered = opts.format.is_rendered(); if matches!(opts.format, F::Jpeg | F::Tiff | F::Avif | F::Png | F::Jxl) { let tip = crate::i18n::tr( "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float", @@ -1197,6 +1232,11 @@ fn export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)); } } +} + +/// Export dialog: quality and the other options of a lossy or JPEG XL file. +fn export_quality_rows(ui: &mut egui::Ui, opts: &mut ExportOptions, limit_kb: &mut u32) { + use lightcraft_engine::export::ExportFormat as F; if opts.format == F::Jxl { let tip = "Mathematically lossless: every pixel is kept exactly (larger files)"; ui.checkbox(&mut opts.jxl_lossless, crate::i18n::tr("Lossless")).on_hover_text(crate::i18n::tr(tip)); @@ -1219,121 +1259,128 @@ fn export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut *limit_kb = k as u32; } } - if rendered { - export_size(ui, full_size, resize, &mut opts.ppi); +} + +/// Export dialog: sharpening, metadata and the watermark of a rendered photo. +fn export_output_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut ExportOptions, full_size: bool, resize: Resize) { + use lightcraft_engine::export::{MetadataPolicy as M, SharpenAmount as A, SharpenFor as S}; + choices( + ui, + "Sharpen", + "exportSharpen", + &[(S::None, "None"), (S::Screen, "Screen"), (S::Matte, "Matte"), (S::Glossy, "Glossy")], + &mut opts.sharpen, + ); + if opts.sharpen != S::None { + choices(ui, "Amount", "exportSharpenAmount", &[(A::Low, "Low"), (A::Standard, "Standard"), (A::High, "High")], &mut opts.sharpen_amount); } - if rendered { - choices( - ui, - "Sharpen", - "exportSharpen", - &[(S::None, "None"), (S::Screen, "Screen"), (S::Matte, "Matte"), (S::Glossy, "Glossy")], - &mut opts.sharpen, - ); - if opts.sharpen != S::None { - choices(ui, "Amount", "exportSharpenAmount", &[(A::Low, "Low"), (A::Standard, "Standard"), (A::High, "High")], &mut opts.sharpen_amount); - } + choices( + ui, + "Metadata", + "exportMetadata", + &[(M::All, "All"), (M::AllExceptCamera, "No camera"), (M::Copyright, "Copyright"), (M::None, "None")], + &mut opts.metadata, + ); + if !matches!(opts.metadata, M::None | M::Copyright) { + ui.checkbox(&mut opts.remove_location, crate::i18n::tr("Remove location info")); } - if rendered { - choices( - ui, - "Metadata", - "exportMetadata", - &[(M::All, "All"), (M::AllExceptCamera, "No camera"), (M::Copyright, "Copyright"), (M::None, "None")], - &mut opts.metadata, - ); - if !matches!(opts.metadata, M::None | M::Copyright) { - ui.checkbox(&mut opts.remove_location, crate::i18n::tr("Remove location info")); - } - let mut wm_on = opts.watermark.is_some(); - if ui.checkbox(&mut wm_on, crate::i18n::tr("Watermark")).changed() { - opts.watermark = wm_on.then(|| lightcraft_engine::export::Watermark { text: "© ".into(), ..Default::default() }); - } - let wm_shown = opts.watermark.clone(); - if let Some(wm) = &wm_shown { - watermark_preview(app, ui, wm, (!*full_size).then_some(*resize)); - } - if let Some(wm) = &mut opts.watermark { - // text, or a graphic (a logo with transparency) - let mut graphic = !wm.image.is_empty() || ui.data(|d| d.get_temp::(egui::Id::unique("wm-graphic"))).unwrap_or(false); - let before = graphic; - choices(ui, "Style", "exportWmStyle", &[(false, "Text"), (true, "Graphic")], &mut graphic); - if graphic != before { - ui.data_mut(|d| d.insert_temp(egui::Id::unique("wm-graphic"), graphic)); - if !graphic { - wm.image.clear(); - } - } - if graphic { - field(ui, "Graphic", |ui| { - trailing_button_row(ui, |ui| { - if app.services.pick_files.is_some() - && crate::widgets::text_button(ui, "exportWmChoose", crate::i18n::tr("Choose…"), false).clicked() - && let Some(f) = app.services.pick_files.as_mut().and_then(|pick| pick().into_iter().next()) - { - wm.image = f; - } - crate::widgets::named( - ui.add(egui::TextEdit::singleline(&mut wm.image).hint_text("logo.png").desired_width(ui.available_width())), - crate::i18n::tr("Image"), - ); - }); - }); - let mut width = wm.image_width as f64 * 100.0; - if num(ui, &WM_IMAGE_WIDTH, &mut width) { - wm.image_width = (width / 100.0) as f32; - } - } else { - choices( - ui, - app.ui.language.tr("Text direction"), - "exportWmOrientation", - &[(false, app.ui.language.tr("Horizontal text")), (true, app.ui.language.tr("Vertical text"))], - &mut wm.vertical, - ); - field(ui, "Text", |ui| { - crate::widgets::named( - ui.add( - egui::TextEdit::multiline(&mut wm.text) - .desired_rows(2) - .hint_text(crate::i18n::tr("© Your Name")) - .desired_width(f32::INFINITY), - ), - crate::i18n::tr("Text"), - ) - }); - } - choices(ui, "Position", "exportWmAnchor", &WM_POSITIONS, &mut wm.anchor); - choices(ui, "Rotate", "exportWmRotation", &[(0u16, "0°"), (90, "90°"), (180, "180°"), (270, "270°")], &mut wm.rotation); - let mut size = wm.size as f64 * 100.0; - if !graphic && num(ui, &WM_SIZE, &mut size) { - wm.size = (size / 100.0) as f32; - } - let mut op = wm.opacity as f64 * 100.0; - if num(ui, &WM_OPACITY, &mut op) { - wm.opacity = (op / 100.0) as f32; - } - // like Lightroom Classic: a margin from the side edges and one from the top and bottom - // edges, each unused (greyed) when the position is centred along that axis - use lightcraft_engine::export::Anchor as P; - let across = !matches!(wm.anchor, P::Top | P::Center | P::Bottom); - if let Some(v) = crate::widgets::slider(ui, &WM_INSET_X, wm.inset_x as f64 * 100.0, across, None).value { - wm.inset_x = (v / 100.0) as f32; - } - let down = !matches!(wm.anchor, P::Left | P::Center | P::Right); - if let Some(v) = crate::widgets::slider(ui, &WM_INSET_Y, wm.inset_y as f64 * 100.0, down, None).value { - wm.inset_y = (v / 100.0) as f32; - } + export_watermark(app, ui, opts, full_size, resize); +} + +/// Export dialog: the watermark switch, its preview and its settings. +fn export_watermark(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut ExportOptions, full_size: bool, resize: Resize) { + let mut wm_on = opts.watermark.is_some(); + if ui.checkbox(&mut wm_on, crate::i18n::tr("Watermark")).changed() { + opts.watermark = wm_on.then(|| lightcraft_engine::export::Watermark { text: "© ".into(), ..Default::default() }); + } + let wm_shown = opts.watermark.clone(); + if let Some(wm) = &wm_shown { + watermark_preview(app, ui, wm, (!full_size).then_some(resize)); + } + if let Some(wm) = &mut opts.watermark { + // text, or a graphic (a logo with transparency) + let mut graphic = !wm.image.is_empty() || ui.data(|d| d.get_temp::(egui::Id::unique("wm-graphic"))).unwrap_or(false); + let before = graphic; + choices(ui, "Style", "exportWmStyle", &[(false, "Text"), (true, "Graphic")], &mut graphic); + if graphic != before { + ui.data_mut(|d| d.insert_temp(egui::Id::unique("wm-graphic"), graphic)); if !graphic { - ui.checkbox(&mut wm.shadow, crate::i18n::tr("Shadow")); + wm.image.clear(); } } - if opts.watermark != wm_shown { - // the preview above was drawn with the old settings - ui.ctx().request_repaint(); + if graphic { + field(ui, "Graphic", |ui| { + trailing_button_row(ui, |ui| { + if app.services.pick_files.is_some() + && crate::widgets::text_button(ui, "exportWmChoose", crate::i18n::tr("Choose…"), false).clicked() + && let Some(f) = app.services.pick_files.as_mut().and_then(|pick| pick().into_iter().next()) + { + wm.image = f; + } + crate::widgets::named( + ui.add(egui::TextEdit::singleline(&mut wm.image).hint_text("logo.png").desired_width(ui.available_width())), + crate::i18n::tr("Image"), + ); + }); + }); + let mut width = wm.image_width as f64 * 100.0; + if num(ui, &WM_IMAGE_WIDTH, &mut width) { + wm.image_width = (width / 100.0) as f32; + } + } else { + choices( + ui, + app.ui.language.tr("Text direction"), + "exportWmOrientation", + &[(false, app.ui.language.tr("Horizontal text")), (true, app.ui.language.tr("Vertical text"))], + &mut wm.vertical, + ); + field(ui, "Text", |ui| { + crate::widgets::named( + ui.add( + egui::TextEdit::multiline(&mut wm.text) + .desired_rows(2) + .hint_text(crate::i18n::tr("© Your Name")) + .desired_width(f32::INFINITY), + ), + crate::i18n::tr("Text"), + ) + }); + } + choices(ui, "Position", "exportWmAnchor", &WM_POSITIONS, &mut wm.anchor); + choices(ui, "Rotate", "exportWmRotation", &[(0u16, "0°"), (90, "90°"), (180, "180°"), (270, "270°")], &mut wm.rotation); + let mut size = wm.size as f64 * 100.0; + if !graphic && num(ui, &WM_SIZE, &mut size) { + wm.size = (size / 100.0) as f32; + } + let mut op = wm.opacity as f64 * 100.0; + if num(ui, &WM_OPACITY, &mut op) { + wm.opacity = (op / 100.0) as f32; + } + // like Lightroom Classic: a margin from the side edges and one from the top and bottom + // edges, each unused (greyed) when the position is centred along that axis + use lightcraft_engine::export::Anchor as P; + let across = !matches!(wm.anchor, P::Top | P::Center | P::Bottom); + if let Some(v) = crate::widgets::slider(ui, &WM_INSET_X, wm.inset_x as f64 * 100.0, across, None).value { + wm.inset_x = (v / 100.0) as f32; + } + let down = !matches!(wm.anchor, P::Left | P::Center | P::Right); + if let Some(v) = crate::widgets::slider(ui, &WM_INSET_Y, wm.inset_y as f64 * 100.0, down, None).value { + wm.inset_y = (v / 100.0) as f32; + } + if !graphic { + ui.checkbox(&mut wm.shadow, crate::i18n::tr("Shadow")); } } - ui.add_space(4.0); + if opts.watermark != wm_shown { + // the preview above was drawn with the old settings + ui.ctx().request_repaint(); + } +} + +/// Export dialog: compression and the file name. +fn export_naming_rows(ui: &mut egui::Ui, opts: &mut ExportOptions) { + use lightcraft_engine::export::ExportFormat as F; if opts.format == F::Tiff { use lightcraft_engine::export::TiffCompression as Z; choices(ui, "Compression", "exportTiffCompression", &[(Z::None, "None"), (Z::Lzw, "LZW"), (Z::Deflate, "ZIP")], &mut opts.tiff_compression); @@ -1373,6 +1420,10 @@ fn export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut opts.start_number = v as u32; } } +} + +/// Export dialog: where the files go and what happens when one exists. +fn export_destination_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, opts: &mut ExportOptions, dir: &mut String) { // like Lightroom Classic's Export To: one folder, or each photo's own folder (not on // the web, where originals live in the browser's storage, not in folders) use lightcraft_engine::export::DestinationMode as D; @@ -1407,36 +1458,49 @@ fn export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut }); use lightcraft_engine::export::Conflict as K; choices(ui, "If file exists", "exportConflict", &[(K::Unique, "Add number"), (K::Overwrite, "Overwrite"), (K::Skip, "Skip")], &mut opts.conflict); - // After export: Apple Photos imports the files written (macOS only) - #[cfg(target_os = "macos")] - { - let r = ui.checkbox(&mut opts.add_to_photos, crate::i18n::tr("Add to Apple Photos")); - crate::widgets::register(ui, "exportAddToPhotos", r.rect); - if opts.add_to_photos { - field(ui, "Album", |ui| { - crate::widgets::named( - ui.add(egui::TextEdit::singleline(&mut opts.photos_album).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), - crate::i18n::tr("Album"), - ) - }); - ui.label( - egui::RichText::new(crate::i18n::tr("The first time, macOS asks whether LightCraft may control Photos.")) - .size(11.0) - .color(t.text_dim), - ); - } - } - // nowhere to add them: a setting carried over from a Mac is dropped, not refused - #[cfg(not(target_os = "macos"))] - { - opts.add_to_photos = false; + export_apple_photos_rows(ui, t, opts); +} + +/// Export dialog, after export: Apple Photos imports the files written (macOS only). +#[cfg(target_os = "macos")] +fn export_apple_photos_rows(ui: &mut egui::Ui, t: &Tokens, opts: &mut ExportOptions) { + let r = ui.checkbox(&mut opts.add_to_photos, crate::i18n::tr("Add to Apple Photos")); + crate::widgets::register(ui, "exportAddToPhotos", r.rect); + if opts.add_to_photos { + field(ui, "Album", |ui| { + crate::widgets::named( + ui.add(egui::TextEdit::singleline(&mut opts.photos_album).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), + crate::i18n::tr("Album"), + ) + }); + ui.label( + egui::RichText::new(crate::i18n::tr("The first time, macOS asks whether LightCraft may control Photos.")).size(11.0).color(t.text_dim), + ); } +} + +/// Nowhere to add them: a setting carried over from a Mac is dropped, not refused. +#[cfg(not(target_os = "macos"))] +fn export_apple_photos_rows(_ui: &mut egui::Ui, _t: &Tokens, opts: &mut ExportOptions) { + opts.add_to_photos = false; +} + +/// Export dialog: save the options as a preset. +fn export_save_preset_row( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + opts: &ExportOptions, + full_size: bool, + resize: &Resize, + limit_kb: u32, + preset_name: &mut String, +) { ui.add_space(4.0); field(ui, "Save preset", |ui| { trailing_button_row(ui, |ui| { let named = !preset_name.trim().is_empty(); if crate::widgets::text_button(ui, "exportSavePreset", crate::i18n::tr("Save"), false).clicked() && named { - let params = export_dialog_params(opts, *full_size, resize, *limit_kb); + let params = export_dialog_params(opts, full_size, resize, limit_kb); match app.run("export.savePreset", json!({"name": preset_name.trim(), "params": params})) { Ok(_) => { app.toast(ui.ctx(), crate::i18n::tr_format!("Saved export preset “{}”", preset_name.trim())); From 7b4236bc8b5e00ed081e15bfea9241c299e28723 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 20:40:33 +0000 Subject: [PATCH 32/39] refactor(ui-egui): pass related arguments as small structs --- .../src/dialogs/settings/appearance.rs | 31 ++++--- .../src/dialogs/settings/slide_overlays.rs | 67 ++++++++++---- crates/ui-egui/src/panels.rs | 25 +++-- crates/ui-egui/src/panels/detail.rs | 22 +++-- crates/ui-egui/src/panels/detail/masks.rs | 18 +++- crates/ui-egui/src/panels/left.rs | 17 +++- crates/ui-egui/src/panels/left/albums.rs | 12 ++- crates/ui-egui/src/panels/left/dates.rs | 4 +- crates/ui-egui/src/panels/left/folders.rs | 8 +- crates/ui-egui/src/panels/left/keywords.rs | 4 +- crates/ui-egui/src/panels/left/local.rs | 10 +- crates/ui-egui/src/panels/left/rows.rs | 92 +++++++++++-------- crates/ui-egui/src/panels/person.rs | 24 ++++- crates/ui-egui/src/tests/lifecycle_037.rs | 17 +++- 14 files changed, 233 insertions(+), 118 deletions(-) diff --git a/crates/ui-egui/src/dialogs/settings/appearance.rs b/crates/ui-egui/src/dialogs/settings/appearance.rs index 571db16fc..f714a97e6 100644 --- a/crates/ui-egui/src/dialogs/settings/appearance.rs +++ b/crates/ui-egui/src/dialogs/settings/appearance.rs @@ -17,9 +17,15 @@ pub(super) fn appearance(ui: &mut egui::Ui, t: &Tokens, s: &mut crate::state::Ap let mut cards = |ui: &mut egui::Ui, width: f32| { ui.spacing_mut().item_spacing.x = 12.0; let light: Vec<_> = LightTheme::ALL.iter().map(|l| (*l, l.kind())).collect(); - theme_card(ui, t, "Light Theme", light_active, &light, &mut s.light_theme, &mut s.favorite_themes, width); + theme_card( + ui, + t, + ThemeCard { title: "Light Theme", active: light_active, options: &light, width }, + &mut s.light_theme, + &mut s.favorite_themes, + ); let dark: Vec<_> = DarkTheme::ALL.iter().map(|d| (*d, d.kind())).collect(); - theme_card(ui, t, "Dark Theme", !light_active, &dark, &mut s.dark_theme, &mut s.favorite_themes, width); + theme_card(ui, t, ThemeCard { title: "Dark Theme", active: !light_active, options: &dark, width }, &mut s.dark_theme, &mut s.favorite_themes); }; if available < 440.0 { ui.vertical(|ui| cards(ui, available)); @@ -29,18 +35,19 @@ pub(super) fn appearance(ui: &mut egui::Ui, t: &Tokens, s: &mut crate::state::Ap ui.add_space(2.0); } +/// What a theme family's card shows. +struct ThemeCard<'a, V> { + title: &'a str, + /// the family in use + active: bool, + options: &'a [(V, crate::theme::ThemeKind)], + width: f32, +} + /// One family's card: its title (and "Active" when it is the one shown), a preview of the /// selected theme and a radio button per theme. -fn theme_card( - ui: &mut egui::Ui, - t: &Tokens, - title: &str, - active: bool, - options: &[(V, crate::theme::ThemeKind)], - value: &mut V, - favorites: &mut Vec, - width: f32, -) { +fn theme_card(ui: &mut egui::Ui, t: &Tokens, card: ThemeCard, value: &mut V, favorites: &mut Vec) { + let ThemeCard { title, active, options, width } = card; egui::Frame::new() .fill(t.inset) .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.field_border })) diff --git a/crates/ui-egui/src/dialogs/settings/slide_overlays.rs b/crates/ui-egui/src/dialogs/settings/slide_overlays.rs index 3d7412b58..fe72ed6a6 100644 --- a/crates/ui-egui/src/dialogs/settings/slide_overlays.rs +++ b/crates/ui-egui/src/dialogs/settings/slide_overlays.rs @@ -30,14 +30,21 @@ fn set(app: &mut LightcraftApp, p: Value) { let _ = app.run("slideshow.overlays", p); } -/// A slider (`slider:{id}`) over `lo..=hi`; `wrap(value)` makes the `slideshow.overlays` request. -fn slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: f64, lo: f64, hi: f64, unit: Unit, wrap: &dyn Fn(f64) -> Value) { - let (k, suffix) = match unit { +/// The span of a slider and how its value is shown. +struct Range { + lo: f64, + hi: f64, + unit: Unit, +} + +/// A slider (`slider:{id}`) over `range`; `wrap(value)` makes the `slideshow.overlays` request. +fn slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: f64, range: Range, wrap: &dyn Fn(f64) -> Value) { + let (k, suffix) = match range.unit { Unit::Percent => (100.0, " %"), Unit::Degrees => (1.0, "°"), }; let mut v = value * k; - let r = ui.add(egui::Slider::new(&mut v, lo * k..=hi * k).suffix(suffix).max_decimals(1)); + let r = ui.add(egui::Slider::new(&mut v, range.lo * k..=range.hi * k).suffix(suffix).max_decimals(1)); register(ui, format!("slider:{id}"), r.rect); let p = wrap(v / k); if r.changed() @@ -125,10 +132,14 @@ fn placement(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, id: &str, p }); } row(ui, t, "Offset X", |ui| { - slider(app, ui, &format!("{id}OffsetX"), p.offset_x, -OFFSET_MAX / 2.0, OFFSET_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"offsetX": v}))) + slider(app, ui, &format!("{id}OffsetX"), p.offset_x, Range { lo: -OFFSET_MAX / 2.0, hi: OFFSET_MAX / 2.0, unit: Unit::Percent }, &|v| { + wrap(json!({"offsetX": v})) + }) }); row(ui, t, "Offset Y", |ui| { - slider(app, ui, &format!("{id}OffsetY"), p.offset_y, -OFFSET_MAX / 2.0, OFFSET_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"offsetY": v}))) + slider(app, ui, &format!("{id}OffsetY"), p.offset_y, Range { lo: -OFFSET_MAX / 2.0, hi: OFFSET_MAX / 2.0, unit: Unit::Percent }, &|v| { + wrap(json!({"offsetY": v})) + }) }); } @@ -171,8 +182,12 @@ pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { row(ui, t, "Text", |ui| text(app, ui, "slideshowPlateText", &p.text, "Your name or studio", &|v| plate(json!({"text": v})))); row(ui, t, "Colour", |ui| colour(app, ui, "slideshowPlateColor", p.color, &|c| plate(json!({"color": c})))); } - row(ui, t, "Size", |ui| slider(app, ui, "slideshowPlateSize", p.size, 0.01, PLATE_SIZE_MAX, Unit::Percent, &|v| plate(json!({"size": v})))); - row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowPlateOpacity", p.opacity, 0.0, 1.0, Unit::Percent, &|v| plate(json!({"opacity": v})))); + row(ui, t, "Size", |ui| { + slider(app, ui, "slideshowPlateSize", p.size, Range { lo: 0.01, hi: PLATE_SIZE_MAX, unit: Unit::Percent }, &|v| plate(json!({"size": v}))) + }); + row(ui, t, "Opacity", |ui| { + slider(app, ui, "slideshowPlateOpacity", p.opacity, Range { lo: 0.0, hi: 1.0, unit: Unit::Percent }, &|v| plate(json!({"opacity": v}))) + }); placement(app, ui, t, "slideshowPlate", &p.place, true, &plate); toggle(app, ui, "slideshowPlateBehind", p.behind, "Render behind image", &|v| plate(json!({"behind": v}))); } @@ -183,7 +198,9 @@ pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let stars = |v: Value| json!({ "ratingStars": v }); if toggle(app, ui, "slideshowStars", r.enabled, "Rating stars", &|v| stars(json!({"enabled": v}))) { row(ui, t, "Colour", |ui| colour(app, ui, "slideshowStarsColor", r.color, &|c| stars(json!({"color": c})))); - row(ui, t, "Size", |ui| slider(app, ui, "slideshowStarsSize", r.size, 0.01, STAR_SIZE_MAX, Unit::Percent, &|v| stars(json!({"size": v})))); + row(ui, t, "Size", |ui| { + slider(app, ui, "slideshowStarsSize", r.size, Range { lo: 0.01, hi: STAR_SIZE_MAX, unit: Unit::Percent }, &|v| stars(json!({"size": v}))) + }); placement(app, ui, t, "slideshowStars", &r.place, true, &stars); } @@ -205,24 +222,36 @@ pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { return; // removed: the indices moved; drawn again next frame } row(ui, t, "Size", |ui| { - slider(app, ui, &format!("{id}Size"), o.size, 0.01, TEXT_SIZE_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"size": v}))) + slider(app, ui, &format!("{id}Size"), o.size, Range { lo: 0.01, hi: TEXT_SIZE_MAX / 2.0, unit: Unit::Percent }, &|v| { + wrap(json!({"size": v})) + }) }); row(ui, t, "Colour", |ui| colour(app, ui, &format!("{id}Color"), o.color, &|c| wrap(json!({"color": c})))); - row(ui, t, "Opacity", |ui| slider(app, ui, &format!("{id}Opacity"), o.opacity, 0.0, 1.0, Unit::Percent, &|v| wrap(json!({"opacity": v})))); + row(ui, t, "Opacity", |ui| { + slider(app, ui, &format!("{id}Opacity"), o.opacity, Range { lo: 0.0, hi: 1.0, unit: Unit::Percent }, &|v| wrap(json!({"opacity": v}))) + }); placement(app, ui, t, &id, &o.place, true, &wrap); let sh = o.shadow; if toggle(app, ui, &format!("{id}Shadow"), sh.on, "Shadow", &|v| wrap(json!({"shadow": v}))) { row(ui, t, "Opacity", |ui| { - slider(app, ui, &format!("{id}ShadowOpacity"), sh.opacity, 0.0, 1.0, Unit::Percent, &|v| wrap(json!({"shadowOpacity": v}))) + slider(app, ui, &format!("{id}ShadowOpacity"), sh.opacity, Range { lo: 0.0, hi: 1.0, unit: Unit::Percent }, &|v| { + wrap(json!({"shadowOpacity": v})) + }) }); row(ui, t, "Offset", |ui| { - slider(app, ui, &format!("{id}ShadowOffset"), sh.offset, 0.0, SHADOW_OFFSET_MAX, Unit::Percent, &|v| wrap(json!({"shadowOffset": v}))) + slider(app, ui, &format!("{id}ShadowOffset"), sh.offset, Range { lo: 0.0, hi: SHADOW_OFFSET_MAX, unit: Unit::Percent }, &|v| { + wrap(json!({"shadowOffset": v})) + }) }); row(ui, t, "Radius", |ui| { - slider(app, ui, &format!("{id}ShadowRadius"), sh.radius, 0.0, SHADOW_RADIUS_MAX, Unit::Percent, &|v| wrap(json!({"shadowRadius": v}))) + slider(app, ui, &format!("{id}ShadowRadius"), sh.radius, Range { lo: 0.0, hi: SHADOW_RADIUS_MAX, unit: Unit::Percent }, &|v| { + wrap(json!({"shadowRadius": v})) + }) }); row(ui, t, "Angle", |ui| { - slider(app, ui, &format!("{id}ShadowAngle"), sh.angle, -180.0, 180.0, Unit::Degrees, &|v| wrap(json!({"shadowAngle": v}))) + slider(app, ui, &format!("{id}ShadowAngle"), sh.angle, Range { lo: -180.0, hi: 180.0, unit: Unit::Degrees }, &|v| { + wrap(json!({"shadowAngle": v})) + }) }); } } @@ -241,9 +270,13 @@ pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let wm = |v: Value| json!({ "watermark": v }); if toggle(app, ui, "slideshowWatermark", s.show_watermark, "Watermark", &|v| json!({"showWatermark": v})) { row(ui, t, "Text", |ui| text(app, ui, "slideshowWatermarkText", &w.text, "© Your Name", &|v| wm(json!({"text": v})))); - row(ui, t, "Size", |ui| slider(app, ui, "slideshowWatermarkSize", w.size as f64, 0.01, 0.15, Unit::Percent, &|v| wm(json!({"size": v})))); + row(ui, t, "Size", |ui| { + slider(app, ui, "slideshowWatermarkSize", w.size as f64, Range { lo: 0.01, hi: 0.15, unit: Unit::Percent }, &|v| wm(json!({"size": v}))) + }); row(ui, t, "Opacity", |ui| { - slider(app, ui, "slideshowWatermarkOpacity", w.opacity as f64, 0.05, 1.0, Unit::Percent, &|v| wm(json!({"opacity": v}))) + slider(app, ui, "slideshowWatermarkOpacity", w.opacity as f64, Range { lo: 0.05, hi: 1.0, unit: Unit::Percent }, &|v| { + wm(json!({"opacity": v})) + }) }); row(ui, t, "Position", |ui| { let mut a = w.anchor; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 4c2bed935..ed9c45fb5 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -46,21 +46,26 @@ pub use crate::dialogs::settings::slide_templates; pub use crate::dialogs::settings::slide_titles; use crate::theme::Tokens; +/// A side panel the user resizes: which side, how it is drawn and how wide it may be. +pub struct SidePanel<'a> { + /// the left side (else the right) + pub left: bool, + pub id: &'a str, + pub frame: egui::Frame, + /// the width the user chose + pub width: f32, + pub limits: crate::state::PanelWidth, + /// room to leave for what is beside the photo + pub reserve: f32, +} + /// Show a side panel the user resizes by dragging its inner edge. `width` (in [`crate::UiState`], /// so it survives navigation and restarts) is the source of truth: egui's own remembered size is /// dropped every frame, and a drag of the edge returns the new width to store. The panel never /// takes more than `available − reserve − MIN_PHOTO_WIDTH` (its minimum permitting), so the photo /// stays usable; a narrower window shrinks it without forgetting the chosen width. -pub fn resizable_side( - ui: &mut egui::Ui, - left: bool, - id: &str, - frame: egui::Frame, - width: f32, - limits: crate::state::PanelWidth, - reserve: f32, - add: impl FnOnce(&mut egui::Ui), -) -> Option { +pub fn resizable_side(ui: &mut egui::Ui, side: SidePanel, add: impl FnOnce(&mut egui::Ui)) -> Option { + let SidePanel { left, id, frame, width, limits, reserve } = side; // `Panel::new` stores `IdSalt::new(id)`; the panel's persisted key is // `parent_ui.scope_id().with_salt(that_salt)`, which is exactly `scope_id().with(id)`. // (Passing an already-built `Id` through `Panel::left` would salt it a second time.) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index eee9d7d0c..3eb94779c 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -46,7 +46,7 @@ use info::{ preview_only_pill, quick_name, }; pub(crate) use info::{draw_focus_point, failed_pill, loupe_pill, rendering_overdue}; -use masks::{RegionEdit, color_range_pick, mask_overlay, region_overlay}; +use masks::{RegionContext, RegionEdit, color_range_pick, mask_overlay, region_overlay}; use retouch::{eye_overlay, remove_overlay}; pub(crate) use window::overlay_supports_window; use window::{WindowCtx, WindowView, draw_window, request_window}; @@ -541,7 +541,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let people = if app.ui.name_edit.is_some() { app.caches.person_names(&app.session.catalog) } else { Default::default() }; // while the name box is open and recognition is not set up, the box offers the next step let setup = if app.ui.name_edit.is_some() { super::faces::setup(app, ui.ctx()) } else { super::faces::Setup::Running }; - match region_overlay(ui, &p, &map, &photo, d.orientation, hints.as_deref(), &people, &mut app.ui.name_edit, setup) { + let regions = RegionContext { photo: &photo, orient: d.orientation, hints: hints.as_deref(), people: &people, setup }; + match region_overlay(ui, &p, &map, ®ions, &mut app.ui.name_edit) { Some(RegionEdit::SetUp) => { app.ui.name_edit = None; super::faces::take_step(app, ui.ctx(), setup); @@ -596,7 +597,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { (true, Some(before), Some(q)) if before.contains(q) => (CanvasMap::new(&frame, before_img), before_img, *before), _ => (map, img_rect, main_area), }; - general_interaction(app, ui, &resp, &map, img, pane, native, aspect) + general_interaction(app, ui, &resp, &map, &PhotoPlacement { img, canvas: pane, native, aspect }) } } } @@ -761,16 +762,19 @@ fn general_cursor(app: &LightcraftApp, ui: &egui::Ui, resp: &egui::Response, img ui.ctx().set_cursor_icon(cursor); } -fn general_interaction( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - resp: &egui::Response, - map: &CanvasMap, +/// Where the photo is on the canvas. +struct PhotoPlacement { + /// the photo's rectangle img: Rect, + /// the canvas it is shown on canvas: Rect, + /// the photo's size in pixels native: [usize; 2], aspect: f32, -) { +} + +fn general_interaction(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, placement: &PhotoPlacement) { + let PhotoPlacement { img, canvas, native, aspect } = *placement; if app.ui.tool == "wbPicker" { if resp.clicked() && let Some(q) = resp.interact_pointer_pos() diff --git a/crates/ui-egui/src/panels/detail/masks.rs b/crates/ui-egui/src/panels/detail/masks.rs index 50424f6b3..c150eac53 100644 --- a/crates/ui-egui/src/panels/detail/masks.rs +++ b/crates/ui-egui/src/panels/detail/masks.rs @@ -33,17 +33,25 @@ const REGION_HANDLES: [(f32, f32, egui::CursorIcon); 8] = [ /// on release (one undo step); the × reports the region to remove. Both are catalog-only edits: /// LightCraft doesn't write regions to XMP. Regions are stored on the upright (EXIF-oriented) photo; /// `orient` is the user's Rotate / Flip on top of it, which the loupe's normalized frame includes. +/// What the face/pet/focus regions of a photo are drawn from. +pub(super) struct RegionContext<'a> { + pub photo: &'a lightcraft_catalog::Photo, + /// the user's Rotate / Flip on top of the upright photo + pub orient: lightcraft_geom::Orientation, + pub hints: Option<&'a crate::panels::faces::Hints>, + /// the names to offer when a region is named + pub people: &'a [String], + pub setup: crate::panels::faces::Setup, +} + pub(super) fn region_overlay( ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, - photo: &lightcraft_catalog::Photo, - orient: lightcraft_geom::Orientation, - hints: Option<&crate::panels::faces::Hints>, - people: &[String], + regions: &RegionContext, editing: &mut Option, - setup: crate::panels::faces::Setup, ) -> Option { + let RegionContext { photo, orient, hints, people, setup } = *regions; let t = Tokens::get(p.ctx()); let clip = p.clip_rect(); let pointer = ui.input(|i| i.pointer.hover_pos()); diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 522afe084..27b86aa0d 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -30,7 +30,7 @@ pub(crate) use local::{fs_cached, fs_cached_running, same_folder}; use local::{local_now, local_rows}; #[cfg(test)] pub(crate) use rows::{INDENT_STEP, MIN_INDENT_STEP}; -use rows::{LIBFOLDER_REVEALED, SCROLL_TO_ROW, row, set_tree_open}; +use rows::{LIBFOLDER_REVEALED, Row, SCROLL_TO_ROW, set_tree_open}; pub(crate) use rows::{content_width, indent_step, step_for}; pub(crate) use sections::PlacedSection; pub use sections::section_drag_feedback; @@ -90,18 +90,27 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ("recentlyAdded", Icon::Clock, "Recently Added", None, LibrarySource::RecentlyAdded), ("picks", Icon::FlagPick, "Picks", Some(picks), LibrarySource::Picks), ] { - if row(app, ui, id, icon, label, count, src == s, 0.0).clicked() { + if Row::new(id, icon, label).count(count).selected(src == s).show(app, ui).clicked() { let _ = app.run("library.source", json!({"kind": id})); } } // photos whose files can't be found (checked every few seconds, not every frame) let missing = missing_count(app, ui); if (missing > 0 || src == LibrarySource::Missing) - && row(app, ui, "missing", Icon::Folder, "Missing Photos", Some(missing), src == LibrarySource::Missing, 0.0).clicked() + && Row::new("missing", Icon::Folder, "Missing Photos") + .count(Some(missing)) + .selected(src == LibrarySource::Missing) + .show(app, ui) + .clicked() { let _ = app.run("library.source", json!({"kind": "missing"})); } - if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0).clicked() { + if Row::new("recentlyDeleted", Icon::Trash, "Recently Deleted") + .count(Some(deleted)) + .selected(src == LibrarySource::RecentlyDeleted) + .show(app, ui) + .clicked() + { let _ = app.run("library.source", json!({"kind": "recentlyDeleted"})); } // the album just made: the folders down to it open, once (also when the section is shut) diff --git a/crates/ui-egui/src/panels/left/albums.rs b/crates/ui-egui/src/panels/left/albums.rs index bfcc6a219..5237f69b8 100644 --- a/crates/ui-egui/src/panels/left/albums.rs +++ b/crates/ui-egui/src/panels/left/albums.rs @@ -1,5 +1,5 @@ use super::folders::{folder_menu, is_within}; -use super::rows::{INDENT_STEP, indent_x, row_sensed, set_tree_open, tree_open, tree_toggle}; +use super::rows::{INDENT_STEP, Row, indent_x, set_tree_open, tree_open, tree_toggle}; use super::{Album, AlbumId, Align2, Icon, LibrarySource, LightcraftApp, Rect, Sense, Tokens, json, pos2, register, vec2}; /// Albums: the tree of albums and the folders they are sorted into. `reveal`: an album just made, @@ -34,8 +34,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare open = true; set_tree_open(ui, open_id, true); } - let resp = - row_sensed(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, None, false, indent, Sense::click_and_drag(), None); + let resp = Row::new(&format!("folder:{}", a.id.0), Icon::Folder, &a.name).indent(indent).sense(Sense::click_and_drag()).show(app, ui); // a folder is no source, so its row folds it too; the triangle is the same click, aimed let has_children = all.get(&Some(a.id)).is_some_and(|v| !v.is_empty()); if resp.drag_started() { @@ -74,7 +73,12 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare // a smart album whose rules no longer check (an album they test was deleted) says so let problems = app.caches.smart_album_problems(&app.session.catalog).get(&a.id).cloned().unwrap_or_default(); let label = if problems.is_empty() { label } else { format!("⚠ {label}") }; - let mut resp = row_sensed(app, ui, &format!("album:{}", a.id.0), icon, &label, None, Some(n), sel, indent, Sense::click_and_drag(), None); + let mut resp = Row::new(&format!("album:{}", a.id.0), icon, &label) + .count(Some(n)) + .selected(sel) + .indent(indent) + .sense(Sense::click_and_drag()) + .show(app, ui); if !problems.is_empty() { crate::widgets::register(ui, format!("albumProblem:{}", a.id.0), resp.rect); } diff --git a/crates/ui-egui/src/panels/left/dates.rs b/crates/ui-egui/src/panels/left/dates.rs index 9b822c6e5..2971cb2f4 100644 --- a/crates/ui-egui/src/panels/left/dates.rs +++ b/crates/ui-egui/src/panels/left/dates.rs @@ -1,4 +1,4 @@ -use super::rows::{INDENT_STEP, row, tree_open, tree_toggle}; +use super::rows::{INDENT_STEP, Row, tree_open, tree_toggle}; use super::{Icon, LibrarySource, LightcraftApp, json}; /// By Date: year → month → day; a click filters by that prefix, the triangle opens a level. @@ -24,7 +24,7 @@ fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str, let open_id = egui::Id::unique(("date-open", key.to_string())); let mut open = tree_open(ui, open_id, false); let sel = app.session.filter.date.as_deref() == Some(key); - let resp = row(app, ui, &format!("date:{key}"), Icon::Clock, label, Some(count), sel, indent); + let resp = Row::new(&format!("date:{key}"), Icon::Clock, label).count(Some(count)).selected(sel).indent(indent).show(app, ui); if key.len() < 10 { tree_toggle(ui, &resp, indent, open_id, &mut open, format!("dateToggle:{key}")); } diff --git a/crates/ui-egui/src/panels/left/folders.rs b/crates/ui-egui/src/panels/left/folders.rs index 1ded12f07..20c1bbce1 100644 --- a/crates/ui-egui/src/panels/left/folders.rs +++ b/crates/ui-egui/src/panels/left/folders.rs @@ -1,7 +1,7 @@ use super::albums::pointer_over_row; use super::local::{same_folder, subfolders}; -use super::rows::{INDENT_STEP, LIBFOLDER_REVEALED, out_of_sight, row, row_sensed, set_tree_open, tree_open, tree_toggle}; -use super::{Album, FolderNode, Icon, LibrarySource, LightcraftApp, Sense, Tokens, json, register, vec2}; +use super::rows::{INDENT_STEP, LIBFOLDER_REVEALED, Row, out_of_sight, set_tree_open, tree_open, tree_toggle}; +use super::{Album, FolderNode, Icon, LibrarySource, LightcraftApp, Tokens, json, register, vec2}; /// A folder on disk with a disclosure triangle: click browses it, the triangle lists its /// subfolders. In the tree that holds the folder being browsed (`reveal`), the folders on the @@ -39,7 +39,7 @@ pub(super) fn folder_tree( if !out_of_sight(app, ui, &format!("local:{path}"), name, None, indent, false) { // at the tree's own indent, as in Albums and Folders: the triangle sits in the gutter and the // icon lines up with the rows above - let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent).on_hover_text(path); + let resp = Row::new(&format!("local:{path}"), Icon::Folder, name).selected(sel).indent(indent).show(app, ui).on_hover_text(path); let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("folderToggle:{path}")); if !tr.clicked() && resp.clicked() @@ -341,7 +341,7 @@ fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &s None => name.to_string(), }; let mark = n.label.map(crate::theme::label_color); - let resp = row_sensed(app, ui, id, Icon::Folder, name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark); + let resp = Row::new(id, Icon::Folder, name).spoken(&spoken).count(Some(n.count)).selected(sel).indent(indent).mark(mark).show(app, ui); folder_drop_target(app, ui, &resp, n, open_id, open); let mut toggled = false; if !n.children.is_empty() { diff --git a/crates/ui-egui/src/panels/left/keywords.rs b/crates/ui-egui/src/panels/left/keywords.rs index 4342efcc6..d8e1720a9 100644 --- a/crates/ui-egui/src/panels/left/keywords.rs +++ b/crates/ui-egui/src/panels/left/keywords.rs @@ -1,5 +1,5 @@ use super::dates::browse_all_photos; -use super::rows::{INDENT_STEP, out_of_sight, row, tree_open, tree_toggle}; +use super::rows::{INDENT_STEP, Row, out_of_sight, tree_open, tree_toggle}; use super::{Icon, KeywordNode, LightcraftApp, json}; /// "Keywords": the library's keyword tree with photo counts (`a|b|c` keywords nest). A click @@ -23,7 +23,7 @@ pub(super) fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[ /// One drawn row of [`keyword_rows`]; `open_id` and `open` are its triangle's when it has rows inside it. fn keyword_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &KeywordNode, id: &str, indent: f32, open_id: Option, open: &mut bool) { let sel = app.session.filter.keyword.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(&n.path)); - let resp = row(app, ui, id, Icon::Tag, &n.name, Some(n.count), sel, indent); + let resp = Row::new(id, Icon::Tag, &n.name).count(Some(n.count)).selected(sel).indent(indent).show(app, ui); if let Some(k) = open_id { tree_toggle(ui, &resp, indent, k, open, format!("keywordToggle:{}", n.path)); } diff --git a/crates/ui-egui/src/panels/left/local.rs b/crates/ui-egui/src/panels/left/local.rs index 6d3549570..1308059c8 100644 --- a/crates/ui-egui/src/panels/left/local.rs +++ b/crates/ui-egui/src/panels/left/local.rs @@ -1,5 +1,5 @@ use super::folders::folder_tree; -use super::rows::row; +use super::rows::Row; use super::{Icon, LibrarySource, LightcraftApp, SidebarSection, json, register}; /// Local's folders as they stand this frame, and the folder being browsed among them. @@ -61,8 +61,7 @@ pub(super) fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &Local let reveal = local.owner == Some(i); folder_tree(app, ui, path, name, path, 0.0, current, reveal, transient); } - if app.services.pick_folder.is_some() && row(app, ui, "local:browse", Icon::Plus, crate::i18n::tr("Browse Folder…"), None, false, 0.0).clicked() - { + if app.services.pick_folder.is_some() && Row::new("local:browse", Icon::Plus, crate::i18n::tr("Browse Folder…")).show(app, ui).clicked() { let picked = app.services.pick_folder.as_mut().and_then(|f| f()); if let Some(path) = picked { match app.run("library.browse", json!({"path": path})) { @@ -80,7 +79,8 @@ pub(super) fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &Local 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); - let resp = row(app, ui, "local:restoreHidden", Icon::Folder, &label, None, false, 0.0) + let resp = Row::new("local:restoreHidden", Icon::Folder, &label) + .show(app, ui) .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() { @@ -505,7 +505,7 @@ mod tests { for frame in 0..3 { let raw = HeadlessView::raw_input(egui::vec2(300.0, 200.0), 1.0, frame as f64 / 60.0, vec![]); view.run(raw, |ui| { - super::row(&mut app, ui, "all", super::Icon::Photos, "All Photos", None, false, 0.0); + super::Row::new("all", super::Icon::Photos, "All Photos").show(&mut app, ui); super::folder_tree(&mut app, ui, path, "Pictures", path, 0.0, None, false, false); }); widgets = crate::widgets::take_registry(&view.ctx); diff --git a/crates/ui-egui/src/panels/left/rows.rs b/crates/ui-egui/src/panels/left/rows.rs index edfdff167..ac364aa36 100644 --- a/crates/ui-egui/src/panels/left/rows.rs +++ b/crates/ui-egui/src/panels/left/rows.rs @@ -77,50 +77,66 @@ pub(crate) fn step_for(panel_w: f32, deepest: f32) -> f32 { [16.0, 14.0, 12.0, 10.0].into_iter().find(|step| levels * step <= room).unwrap_or(MIN_INDENT_STEP) } -pub(super) fn row( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - id: &str, +/// One row of the sidebar, as it is drawn: build it with [`Row::new`] and the setters, then [`Row::show`] it. +pub(super) struct Row<'a> { + id: &'a str, icon: Icon, - label: &str, - count: Option, - selected: bool, - indent: f32, -) -> egui::Response { - row_named(app, ui, id, icon, label, None, count, selected, indent) -} - -/// [`row`] whose spoken name is `spoken` when the painted `label` is a shortened form of it. -fn row_named( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - id: &str, - icon: Icon, - label: &str, - spoken: Option<&str>, - count: Option, - selected: bool, - indent: f32, -) -> egui::Response { - row_sensed(app, ui, id, icon, label, spoken, count, selected, indent, Sense::click(), None) -} - -/// [`row_named`] with the given `sense` (rows that can be dragged add `drag`) and a colour -/// label's dot (`mark`) before the count. -#[allow(clippy::too_many_arguments)] -pub(super) fn row_sensed( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - id: &str, - icon: Icon, - label: &str, - spoken: Option<&str>, + label: &'a str, + /// the spoken name when the painted `label` is a shortened form of it + spoken: Option<&'a str>, count: Option, selected: bool, indent: f32, + /// rows that can be dragged add `drag` sense: Sense, + /// a colour label's dot, before the count mark: Option, -) -> egui::Response { +} + +impl<'a> Row<'a> { + pub(super) fn new(id: &'a str, icon: Icon, label: &'a str) -> Self { + Row { id, icon, label, spoken: None, count: None, selected: false, indent: 0.0, sense: Sense::click(), mark: None } + } + + pub(super) fn spoken(mut self, spoken: &'a str) -> Self { + self.spoken = Some(spoken); + self + } + + pub(super) fn count(mut self, count: Option) -> Self { + self.count = count; + self + } + + pub(super) fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + pub(super) fn indent(mut self, indent: f32) -> Self { + self.indent = indent; + self + } + + pub(super) fn sense(mut self, sense: Sense) -> Self { + self.sense = sense; + self + } + + pub(super) fn mark(mut self, mark: Option) -> Self { + self.mark = mark; + self + } + + /// Draw the row; the response is the row's. + pub(super) fn show(self, app: &mut LightcraftApp, ui: &mut egui::Ui) -> egui::Response { + draw_row(app, ui, self) + } +} + +/// Draw a [`Row`]. +fn draw_row(app: &mut LightcraftApp, ui: &mut egui::Ui, row: Row) -> egui::Response { + let Row { id, icon, label, spoken, count, selected, indent, sense, mark } = row; let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label }; let t = Tokens::get(ui.ctx()); let indent = drawn_indent(ui, indent); diff --git a/crates/ui-egui/src/panels/person.rs b/crates/ui-egui/src/panels/person.rs index 98b738cf2..0b6b946aa 100644 --- a/crates/ui-egui/src/panels/person.rs +++ b/crates/ui-egui/src/panels/person.rs @@ -221,7 +221,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { let Some(f) = page.confirmed.get(row * cols + col) else { break }; shown += 1; let min = area.min + vec2(PAD + col as f32 * (edge + super::people::TILE_GAP), PAD + row as f32 * row_h); - if let Hit::Open = tile(app, ui, Rect::from_min_size(min, vec2(edge, edge)), f, ppp, false, &shown_name, 0) { + if let Hit::Open = + tile(app, ui, &TileSpec { r: Rect::from_min_size(min, vec2(edge, edge)), f, ppp, more: false, name: &shown_name, above_count: 0 }) + { hit = Some((Hit::Open, f.clone())); } } @@ -254,7 +256,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { shown += 1; let min = area.min + vec2(PAD + col as f32 * (edge + super::people::TILE_GAP), grid_top + row as f32 * row_h); let above_count = more.iter().filter(|candidate| candidate.score >= f.score).count(); - match tile(app, ui, Rect::from_min_size(min, vec2(edge, edge)), f, ppp, true, &shown_name, above_count) { + match tile(app, ui, &TileSpec { r: Rect::from_min_size(min, vec2(edge, edge)), f, ppp, more: true, name: &shown_name, above_count }) { Hit::Confirm => hit = Some((Hit::Confirm, (*f).clone())), Hit::ConfirmAbove => hit = Some((Hit::ConfirmAbove, (*f).clone())), Hit::Dismiss => hit = Some((Hit::Dismiss, (*f).clone())), @@ -287,9 +289,25 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { } } +/// What one face tile shows. +struct TileSpec<'a> { + /// where it is drawn + r: Rect, + f: &'a Face, + /// pixels per point, for the size of the face picture + ppp: f32, + /// a "More" tile (a face that looks like the person) rather than a confirmed one + more: bool, + /// the person's name + name: &'a str, + /// how many shown faces look at least as much like the person (for a "More" tile) + above_count: usize, +} + /// One face tile. A confirmed one opens its photo when clicked; a "More" one is confirmed when clicked, and has a × that /// hides it. -fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, f: &Face, ppp: f32, more: bool, name: &str, above_count: usize) -> Hit { +fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, spec: &TileSpec) -> Hit { + let TileSpec { r, f, ppp, more, name, above_count } = *spec; let t = Tokens::get(ui.ctx()); let salt = if more { "more-face" } else { "person-face" }; let resp = ui.interact(r, egui::Id::unique((salt, f.photo, f.index)), Sense::click()); diff --git a/crates/ui-egui/src/tests/lifecycle_037.rs b/crates/ui-egui/src/tests/lifecycle_037.rs index 3c4e8009b..bb6f9d860 100644 --- a/crates/ui-egui/src/tests/lifecycle_037.rs +++ b/crates/ui-egui/src/tests/lifecycle_037.rs @@ -121,9 +121,20 @@ fn resizable_side_stores_panel_state_under_the_parent_salted_key() { let ctx = egui::Context::default(); let raw = raw(egui::vec2(1000.0, 700.0)); frame(&ctx, raw, |ui| { - crate::panels::resizable_side(ui, true, "side-test", egui::Frame::NONE, 260.0, crate::state::LEFT_WIDTH, 300.0, |ui| { - ui.label("side"); - }); + crate::panels::resizable_side( + ui, + crate::panels::SidePanel { + left: true, + id: "side-test", + frame: egui::Frame::NONE, + width: 260.0, + limits: crate::state::LEFT_WIDTH, + reserve: 300.0, + }, + |ui| { + ui.label("side"); + }, + ); }); let modern = ctx.viewport_id().root_ui_id().with("side-test"); assert!( From 620826cac0b7b789a137c010d13485b75b5b7135 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 20:45:29 +0000 Subject: [PATCH 33/39] refactor(ui-egui): shorten functions that format localized messages --- crates/ui-egui/src/dialogs/import/task.rs | 25 +++++------ crates/ui-egui/src/export_task.rs | 55 ++++++++++++----------- crates/ui-egui/src/i18n.rs | 6 +++ crates/ui-egui/src/menus/run.rs | 21 ++++----- crates/ui-egui/src/panels/chips.rs | 47 ++++++++++--------- crates/ui-egui/src/panels/compare.rs | 19 +++++--- crates/ui-egui/src/panels/mask_effects.rs | 35 ++++++++++----- crates/ui-egui/src/panels/notices.rs | 30 ++++++++----- crates/ui-egui/src/widgets.rs | 44 ++++++++++-------- crates/ui-egui/src/widgets/text_field.rs | 25 +++++++---- 10 files changed, 182 insertions(+), 125 deletions(-) diff --git a/crates/ui-egui/src/dialogs/import/task.rs b/crates/ui-egui/src/dialogs/import/task.rs index da2c1574f..e1f506d5b 100644 --- a/crates/ui-egui/src/dialogs/import/task.rs +++ b/crates/ui-egui/src/dialogs/import/task.rs @@ -422,19 +422,14 @@ fn xmp_warnings_text(warnings: &[ImportWarning]) -> String { ) } -/// `s` for a count other than one. -fn plural(n: usize) -> &'static str { - if n == 1 { "" } else { "s" } -} - /// The name of the import's one undo step. fn undo_label(task: &ImportTask) -> String { if task.imported == 0 && task.restored > 0 { - crate::i18n::tr_format!("Restore {} Photo{}", task.restored, plural(task.restored)) + crate::i18n::tr_format!("Restore {} Photo{}", task.restored, crate::i18n::plural_s(task.restored)) } else if task.restored > 0 { - crate::i18n::tr_format!("Add {} Photo{}, restore {}", task.imported, plural(task.imported), task.restored) + crate::i18n::tr_format!("Add {} Photo{}, restore {}", task.imported, crate::i18n::plural_s(task.imported), task.restored) } else { - crate::i18n::tr_format!("Add {} Photo{}", task.imported, plural(task.imported)) + crate::i18n::tr_format!("Add {} Photo{}", task.imported, crate::i18n::plural_s(task.imported)) } } @@ -481,20 +476,20 @@ fn quiet_finish(app: &mut LightcraftApp, ctx: &egui::Context, task: &ImportTask) if !task.warnings.is_empty() { app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0); } else if task.auto && task.imported > 0 { - app.toast(ctx, crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, plural(task.imported))); + app.toast(ctx, crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, crate::i18n::plural_s(task.imported))); } else if !task.auto && task.failed > 0 { - app.toast(ctx, crate::i18n::tr_format!("{} photo{} not readable", task.failed, plural(task.failed))); + app.toast(ctx, crate::i18n::tr_format!("{} photo{} not readable", task.failed, crate::i18n::plural_s(task.failed))); } } /// What an import did, in a sentence. fn import_summary(app: &LightcraftApp, task: &ImportTask) -> String { let mut msg = if task.cancelled { - crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, plural(task.imported)) + crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, crate::i18n::plural_s(task.imported)) } else if task.imported == 0 && task.restored > 0 { - crate::i18n::tr_format!("Restored {} photo{} from Recently Deleted", task.restored, plural(task.restored)) + crate::i18n::tr_format!("Restored {} photo{} from Recently Deleted", task.restored, crate::i18n::plural_s(task.restored)) } else { - crate::i18n::tr_format!("Imported {} photo{}", task.imported, plural(task.imported)) + crate::i18n::tr_format!("Imported {} photo{}", task.imported, crate::i18n::plural_s(task.imported)) }; if task.restored > 0 && (task.imported > 0 || task.cancelled) { msg.push_str(&crate::i18n::tr_format!(" · {} restored from Recently Deleted", task.restored)); @@ -502,11 +497,11 @@ fn import_summary(app: &LightcraftApp, task: &ImportTask) -> String { if task.params["mode"] == "move" { msg.push_str(&crate::i18n::tr_format!(" · {} moved", task.moved)); if task.kept > 0 { - msg.push_str(&crate::i18n::tr_format!(" · {} original{} left at the source", task.kept, plural(task.kept))); + msg.push_str(&crate::i18n::tr_format!(" · {} original{} left at the source", task.kept, crate::i18n::plural_s(task.kept))); } } if task.duplicates > 0 { - msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, plural(task.duplicates))); + msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, crate::i18n::plural_s(task.duplicates))); let deleted = deleted_of(app, &task.existing).len(); if deleted > 0 { msg.push_str(&crate::i18n::tr_format!(" ({} in Recently Deleted)", deleted)); diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index 1aec84820..206161102 100644 --- a/crates/ui-egui/src/export_task.rs +++ b/crates/ui-egui/src/export_task.rs @@ -232,6 +232,35 @@ pub fn poll_photos(app: &mut LightcraftApp, ctx: &egui::Context) { #[cfg(target_arch = "wasm32")] pub fn poll_photos(_: &mut LightcraftApp, _: &egui::Context) {} +/// What a finished export says: how many files were written, skipped or failed, and the Apple Photos note. +fn export_summary(files: &[Value], photos: Option<&Value>, total: usize, cancelled: bool) -> String { + let sheet = files.first().filter(|f| f["contactSheet"] == true); + let ok = files.iter().filter(|f| f.get("path").is_some()).count(); + let failed: Vec<&Value> = files.iter().filter(|f| f.get("error").is_some()).collect(); + let skipped = files.iter().filter(|f| f.get("skipped").is_some()).count(); + let mut m = crate::i18n::tr_format!("Exported {ok} of {total} photo{}", if total == 1 { "" } else { "s" }, ok = ok, total = total); + if let Some(sheet) = sheet { + m = format!("Exported contact sheet: {} photos, {} pages", sheet["photos"], sheet["pages"]); + } + if let Some(pdf) = files.first().filter(|f| f["print"] == true) { + m = format!("Printed to PDF: {} photos, {} pages", pdf["photos"], pdf["pages"]); + } + if skipped > 0 { + m += &crate::i18n::tr_format!(" · {skipped} skipped (file exists)", skipped = skipped); + } + if let Some(first) = failed.first() { + m += &crate::i18n::tr_format!(" · {} failed: {}", failed.len(), first["error"].as_str().unwrap_or("error")); + } + if cancelled { + m += " · cancelled"; + } + if let Some(photos) = photos { + m += " · "; + m += &apple_photos_note(photos); + } + m +} + /// Per frame: keep the activity row up to date; when the batch finishes, report it. pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(task) = &app.export else { return }; @@ -244,31 +273,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { let photos_failed = matches!(&r, Ok((_, Some(p), _)) if p.get("error").is_some()); let msg = match r { Ok((files, photos, _)) => { - let sheet = files.first().filter(|f| f["contactSheet"] == true); - let ok = files.iter().filter(|f| f.get("path").is_some()).count(); - let failed: Vec<&Value> = files.iter().filter(|f| f.get("error").is_some()).collect(); - let skipped = files.iter().filter(|f| f.get("skipped").is_some()).count(); - let mut m = - crate::i18n::tr_format!("Exported {ok} of {total} photo{}", if total == 1 { "" } else { "s" }, ok = ok, total = total); - if let Some(sheet) = sheet { - m = format!("Exported contact sheet: {} photos, {} pages", sheet["photos"], sheet["pages"]); - } - if let Some(pdf) = files.first().filter(|f| f["print"] == true) { - m = format!("Printed to PDF: {} photos, {} pages", pdf["photos"], pdf["pages"]); - } - if skipped > 0 { - m += &crate::i18n::tr_format!(" · {skipped} skipped (file exists)", skipped = skipped); - } - if let Some(first) = failed.first() { - m += &crate::i18n::tr_format!(" · {} failed: {}", failed.len(), first["error"].as_str().unwrap_or("error")); - } - if cancelled { - m += " · cancelled"; - } - if let Some(photos) = &photos { - m += " · "; - m += &apple_photos_note(photos); - } + let m = export_summary(&files, photos.as_ref(), total, cancelled); app.last_export_result = Some(json!({"files": files, "cancelled": cancelled, "applePhotos": photos})); m } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 8be0b3e8e..a7df011c2 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -262,6 +262,12 @@ fn catalog_verbatim(language: Locale) -> BTreeMap { language.catalog().iter().map(|(key, value)| (key.clone(), value.as_str())).collect() } +/// `"s"` for a count other than one: the plural ending of English messages. Computed before a [`tr_format!`] rather +/// than inside it, since the macro repeats its arguments once per language. +pub(crate) fn plural_s(n: usize) -> &'static str { + if n == 1 { "" } else { "s" } +} + // `tr_format!` (one arm per message, one `format!` per language) is generated by `build.rs` from // `locales/*-formats.json`. include!(concat!(env!("OUT_DIR"), "/tr-formats.rs")); diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index ed45cbcb4..c88a50207 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -1869,11 +1869,6 @@ fn failed_files(v: &Value) -> impl Iterator + '_ { }) } -/// `s` for a count other than one. -fn plural(n: usize) -> &'static str { - if n == 1 { "" } else { "s" } -} - /// Import the `.cube` LUTs (and those inside zips / folders) as profiles; the answer to report back. fn import_luts(app: &mut LightcraftApp, lut_paths: &[&String], news: &mut ImportNews) -> Value { match app.session.execute("profile.import", &json!({"paths": lut_paths})) { @@ -1882,7 +1877,7 @@ fn import_luts(app: &mut LightcraftApp, lut_paths: &[&String], news: &mut Import if profiles > 0 { news.parts.push(crate::i18n::tr_format!( "Imported {profiles} profile{} (Profile browser ▸ their groups)", - plural(profiles), + crate::i18n::plural_s(profiles), profiles = profiles )); } @@ -1922,8 +1917,14 @@ fn presets_sentence(v: &Value, profile_news: bool) -> Option { // a zip of LUTs (or only local presets): the other half already says what happened (0, 0) if profile_news => return None, (0, 0) => "No new presets".to_string(), - (n, 0) => crate::i18n::tr_format!("Imported {n} preset{}", plural(n), n = n), - (n, f) => crate::i18n::tr_format!("Imported {n} preset{}, {f} file{} not readable", plural(n), plural(f), f = f, n = n), + (n, 0) => crate::i18n::tr_format!("Imported {n} preset{}", crate::i18n::plural_s(n), n = n), + (n, f) => crate::i18n::tr_format!( + "Imported {n} preset{}, {f} file{} not readable", + crate::i18n::plural_s(n), + crate::i18n::plural_s(f), + f = f, + n = n + ), }; Some(msg + ¬_carried_over(v)) } @@ -2131,11 +2132,11 @@ fn file_import_curve_presets_command(app: &mut LightcraftApp, ctx: &egui::Contex fn curve_import_message(v: &Value) -> String { let n = v["imported"].as_array().map_or(0, Vec::len); let failed = v["failed"].as_array().map_or(0, Vec::len); - let imported = crate::i18n::tr_format!("Imported {n} point curve preset{}", plural(n), n = n); + let imported = crate::i18n::tr_format!("Imported {n} point curve preset{}", crate::i18n::plural_s(n), n = n); if failed == 0 { return imported; } - imported + &crate::i18n::tr_format!(", {failed} file{} not readable", plural(failed), failed = failed) + imported + &crate::i18n::tr_format!(", {failed} file{} not readable", crate::i18n::plural_s(failed), failed = failed) } /// The `file.exportCurvePresets` command. diff --git a/crates/ui-egui/src/panels/chips.rs b/crates/ui-egui/src/panels/chips.rs index c18b3ad72..42640503e 100644 --- a/crates/ui-egui/src/panels/chips.rs +++ b/crates/ui-egui/src/panels/chips.rs @@ -29,6 +29,31 @@ fn chip_width(ui: &egui::Ui, label: &str, font: egui::FontId) -> f32 { ui.painter().layout_no_wrap(label.to_string(), font, egui::Color32::WHITE).size().x + 12.0 + 22.0 } +/// The colour labels a label chip names, in words ("Red or Blue"). +fn label_names(filter: &lightcraft_catalog::Filter, catalog: &lightcraft_catalog::Catalog) -> String { + use crate::i18n::tr; + let shown = filter.labels_shown().unwrap_or_default(); + let name = |label: &lightcraft_catalog::ColorLabel| crate::i18n::color_label(catalog, *label); + let or = |labels: &[lightcraft_catalog::ColorLabel]| labels.iter().map(name).collect::>().join(&format!(" {} ", tr("or"))); + if !shown.is_empty() { + return or(&shown); + } + let plural = if filter.labels.len() > 1 { format!("({})", or(&filter.labels)) } else { or(&filter.labels) }; + format!("{} {} {plural}", filter.label.as_ref().map(name).unwrap_or_default(), tr("and")) +} + +/// The value of a `prefix: value` chip, localised. +fn prefixed_value(prefix: &str, value: &str, filter: &lightcraft_catalog::Filter) -> String { + use crate::i18n::{date_group_label, tr}; + let date = match prefix { + "Flag" | "Type" => return tr(value).to_string(), + "Date" => &filter.date, + "Imported" => &filter.imported, + _ => return value.to_string(), + }; + date.as_ref().map_or_else(|| value.to_string(), |date| date_group_label(date, false)) +} + /// Localise generated chip labels without rewriting search text or metadata values. pub(crate) fn display_label(chip: &FilterChip, filter: &lightcraft_catalog::Filter, catalog: &lightcraft_catalog::Catalog) -> String { use crate::i18n::{date_group_label, rules_label, tr}; @@ -41,16 +66,7 @@ pub(crate) fn display_label(chip: &FilterChip, filter: &lightcraft_catalog::Filt return format!("{}: {}", tr("Rules"), rules_label(rules, catalog)); } if chip.clear.get("label").is_some() { - let shown = filter.labels_shown().unwrap_or_default(); - let name = |label: &lightcraft_catalog::ColorLabel| crate::i18n::color_label(catalog, *label); - let or = |labels: &[lightcraft_catalog::ColorLabel]| labels.iter().map(name).collect::>().join(&format!(" {} ", tr("or"))); - let names = if shown.is_empty() { - let plural = if filter.labels.len() > 1 { format!("({})", or(&filter.labels)) } else { or(&filter.labels) }; - format!("{} {} {plural}", filter.label.as_ref().map(name).unwrap_or_default(), tr("and")) - } else { - or(&shown) - }; - return format!("{}: {names}", tr("Label")); + return format!("{}: {}", tr("Label"), label_names(filter, catalog)); } if chip.clear.get("dateFrom").is_some() || chip.clear.get("dateTo").is_some() { return match (&filter.date_from, &filter.date_to) { @@ -64,16 +80,7 @@ pub(crate) fn display_label(chip: &FilterChip, filter: &lightcraft_catalog::Filt return format!("{} {}", tr("Only"), crate::i18n::tr_format!("{n} photos", n = if filter.only_none { 0 } else { filter.only.len() })); } if let Some((prefix, value)) = chip.label.split_once(": ") { - let value = if matches!(prefix, "Flag" | "Type") { - tr(value).to_string() - } else if prefix == "Date" { - filter.date.as_ref().map_or_else(|| value.to_string(), |date| date_group_label(date, false)) - } else if prefix == "Imported" { - filter.imported.as_ref().map_or_else(|| value.to_string(), |date| date_group_label(date, false)) - } else { - value.to_string() - }; - return format!("{}: {value}", tr(prefix)); + return format!("{}: {}", tr(prefix), prefixed_value(prefix, value, filter)); } if let Some(value) = chip.label.strip_prefix("Rating ") { return format!("{} {value}", tr("Rating")); diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 829500da4..9b9a23673 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -197,6 +197,17 @@ fn area_and_filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui) -> Rect { canvas } +/// Ask for the render of a tile (about `want` pixels on its long edge) and for the thumbnail shown meanwhile. +fn request_tile_renders(app: &mut LightcraftApp, id: PhotoId, slot: Slot, want: usize, aspect: f32) { + let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; + if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), false, true) { + app.renderer.request(slot, job, 60); + } + if let Some(job) = app.session.thumb_job(id, 256) { + app.renderer.request(Slot::Thumb(id), job, 30); + } +} + /// Draw one photo fitted into `area` (rendered at its display size into `slot`), with its caption /// strip below. Returns the image rect and the click/drag response. fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slot, area: Rect, label: &str, zoom: Zoom) -> (Rect, egui::Response) { @@ -230,13 +241,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo let texture_side = super::detail::texture_side(ui.ctx()); let want = (img.width().max(img.height()).min(img_area.width().max(img_area.height()) * 4.0) * ppp).min(2560.0_f32.min(texture_side as f32)) as usize; - let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; - if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), false, true) { - app.renderer.request(slot, job, 60); - } - if let Some(job) = app.session.thumb_job(id, 256) { - app.renderer.request(Slot::Thumb(id), job, 30); - } + request_tile_renders(app, id, slot, want, aspect); // zoomed past what that render holds (1:1 to check focus): the part on screen as a window, as // in the loupe. Compare and Reference only; Survey tiles are fit views. let pane = match slot { diff --git a/crates/ui-egui/src/panels/mask_effects.rs b/crates/ui-egui/src/panels/mask_effects.rs index 86f3796b9..d1c4fe1f4 100644 --- a/crates/ui-egui/src/panels/mask_effects.rs +++ b/crates/ui-egui/src/panels/mask_effects.rs @@ -683,23 +683,38 @@ pub fn manage_body( } } +/// The distinct values, sorted. +fn distinct<'a>(values: impl Iterator) -> Vec<&'a str> { + let mut v: Vec<&str> = values.collect(); + v.sort_unstable(); + v.dedup(); + v +} + /// The toast after an import (`preset.import` / `mask.importEffects` result): how many effects, /// in how many groups, and the settings they carry that LightCraft can't apply. pub fn import_toast(v: &Value) -> String { let rows: Vec<&Value> = v["effects"].as_array().or_else(|| v["imported"].as_array()).into_iter().flatten().collect(); - let n = rows.len(); - let mut msg = crate::i18n::tr_format!("Imported {e} mask effect{} (Masking ▸ Effect)", if n == 1 { "" } else { "s" }, e = n); - let mut groups: Vec<&str> = rows.iter().filter_map(|r| r["group"].as_str()).filter(|g| !g.is_empty()).collect(); - groups.sort_unstable(); - groups.dedup(); + let mut msg = imported_effects_message(rows.len()); + let groups = distinct(rows.iter().filter_map(|r| r["group"].as_str()).filter(|g| !g.is_empty())); if !groups.is_empty() { - msg += &crate::i18n::tr_format!(" in {g} group{}", if groups.len() == 1 { "" } else { "s" }, g = groups.len()); + msg += &in_groups_message(groups.len()); } - let mut skipped: Vec<&str> = rows.iter().flat_map(|r| r["unmapped"].as_array().into_iter().flatten()).filter_map(Value::as_str).collect(); - skipped.sort_unstable(); - skipped.dedup(); + let skipped = distinct(rows.iter().flat_map(|r| r["unmapped"].as_array().into_iter().flatten()).filter_map(Value::as_str)); if !skipped.is_empty() { - msg += &crate::i18n::tr_format!(" — kept but not applied: {}", skipped.join(", ")); + msg += ¬_applied_message(&skipped); } msg } + +fn imported_effects_message(n: usize) -> String { + crate::i18n::tr_format!("Imported {e} mask effect{} (Masking ▸ Effect)", crate::i18n::plural_s(n), e = n) +} + +fn in_groups_message(groups: usize) -> String { + crate::i18n::tr_format!(" in {g} group{}", crate::i18n::plural_s(groups), g = groups) +} + +fn not_applied_message(settings: &[&str]) -> String { + crate::i18n::tr_format!(" — kept but not applied: {}", settings.join(", ")) +} diff --git a/crates/ui-egui/src/panels/notices.rs b/crates/ui-egui/src/panels/notices.rs index 4dbbc1fd9..96e356bb4 100644 --- a/crates/ui-egui/src/panels/notices.rs +++ b/crates/ui-egui/src/panels/notices.rs @@ -40,6 +40,22 @@ pub fn gpu_notice(reason: Option<&str>, enabled: bool) -> Option { Some(text) } +/// The quit prompt's text while `count` cancellable tasks run (`names` lists them). +fn tasks_message(count: usize, names: &str) -> String { + if count == 1 { + crate::i18n::tr_format!("1 task is still running: {names}. Quitting stops it.", names = names) + } else { + crate::i18n::tr_format!("{n} tasks are still running: {names}. Quitting stops them.", n = count, names = names) + } +} + +/// The quit prompt's text when `n` changes couldn't be written to disk, for the reason `e`. +fn unsaved_message(n: usize, e: &str) -> String { + let s = crate::i18n::plural_s(n); + let lost = if n == 1 { "it is" } else { "they are" }; + crate::i18n::tr_format!("{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.", s, lost, n = n, e = e) +} + /// The window may close now (`true`), or the quit prompt is shown (`false`). Running tasks that /// can be cancelled (an import, an export…) are asked about first; then changes that couldn't be /// written are retried — the log, then a snapshot of everything in memory. @@ -51,11 +67,7 @@ pub fn may_close(app: &mut LightcraftApp) -> bool { let running = app.session.activity.running_cancellable(); if !running.is_empty() { let names = running.iter().map(|t| crate::i18n::tr(&t.label)).collect::>().join(", "); - app.quit_prompt = Some(QuitPrompt::Tasks(if running.len() == 1 { - crate::i18n::tr_format!("1 task is still running: {names}. Quitting stops it.", names = names) - } else { - crate::i18n::tr_format!("{n} tasks are still running: {names}. Quitting stops them.", n = running.len(), names = names) - })); + app.quit_prompt = Some(QuitPrompt::Tasks(tasks_message(running.len(), &names))); return false; } if app.session.unsaved().is_none() { @@ -70,13 +82,7 @@ pub fn may_close(app: &mut LightcraftApp) -> bool { match app.session.unsaved() { None => true, Some((n, e)) => { - app.quit_prompt = Some(QuitPrompt::Unsaved(crate::i18n::tr_format!( - "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.", - if n == 1 { "" } else { "s" }, - if n == 1 { "it is" } else { "they are" }, - n = n, - e = e - ))); + app.quit_prompt = Some(QuitPrompt::Unsaved(unsaved_message(n, e))); false } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 558edfe8d..513208066 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -337,24 +337,9 @@ pub(crate) fn shown_value(spec: &ControlSpec, v: f64) -> String { /// scrub rather than a click that types a value. const SCRUB_START: f32 = 3.0; -/// The radius of a slider's hollow ring thumb. -const THUMB_RING: f32 = 7.0; - -/// A Lightroom slider row (label + value above a track with a hollow ring thumb). -/// Double-click the label or thumb to reset (hovering either says so). Shift-drag = fine adjustment. -/// Scroll sideways over it to nudge it. Click the value to type one: Return or clicking away applies it, -/// Esc keeps the old one. Drag the value sideways to scrub it, as if dragging the thumb (Shift: a tenth -/// as far). -pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_override: Option<&str>) -> SliderOut { - let t = Tokens::get(ui.ctx()); - let w = ui.available_width(); - let (row, _) = ui.allocate_exact_size(vec2(w, t.slider_row_h), Sense::hover()); - let pad_l = 24.0; - let pad_r = 22.0; - let label_rect = Rect::from_min_size(pos2(row.left() + pad_l, row.top() + 4.0), vec2(w - pad_l - pad_r, 18.0)); - let track_rect = Rect::from_min_max(pos2(row.left() + pad_l, row.top() + 22.0), pos2(row.right() - pad_r, row.top() + 40.0)); - let id = ui.scope_id().with(spec.id); - let resp = ui.interact(track_rect.expand2(vec2(8.0, 2.0)), id, if enabled { Sense::click_and_drag() } else { Sense::hover() }); +/// Which slider the vertical wheel edits: clicking or dragging a slider arms it (and disarms the others), leaving +/// its row or its focus disarms it. Returns whether this slider is armed, and whether the pointer is in its row. +fn update_wheel_arming(ui: &mut Ui, id: egui::Id, resp: &egui::Response, row: Rect, enabled: bool) -> (bool, bool) { // Keyboard focus survives leaving the row; vertical wheel edits need a fresh click or drag // in it (at most one slider is armed: `wheel_slider_key`). let wheel_id = id.with("wheel-rem"); @@ -394,6 +379,29 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ }); wheel_armed = false; } + (wheel_armed, pointer_in_row) +} + +/// The radius of a slider's hollow ring thumb. +const THUMB_RING: f32 = 7.0; + +/// A Lightroom slider row (label + value above a track with a hollow ring thumb). +/// Double-click the label or thumb to reset (hovering either says so). Shift-drag = fine adjustment. +/// Scroll sideways over it to nudge it. Click the value to type one: Return or clicking away applies it, +/// Esc keeps the old one. Drag the value sideways to scrub it, as if dragging the thumb (Shift: a tenth +/// as far). +pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_override: Option<&str>) -> SliderOut { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + let (row, _) = ui.allocate_exact_size(vec2(w, t.slider_row_h), Sense::hover()); + let pad_l = 24.0; + let pad_r = 22.0; + let label_rect = Rect::from_min_size(pos2(row.left() + pad_l, row.top() + 4.0), vec2(w - pad_l - pad_r, 18.0)); + let track_rect = Rect::from_min_max(pos2(row.left() + pad_l, row.top() + 22.0), pos2(row.right() - pad_r, row.top() + 40.0)); + let id = ui.scope_id().with(spec.id); + let resp = ui.interact(track_rect.expand2(vec2(8.0, 2.0)), id, if enabled { Sense::click_and_drag() } else { Sense::hover() }); + let wheel_id = id.with("wheel-rem"); + let (wheel_armed, pointer_in_row) = update_wheel_arming(ui, id, &resp, row, enabled); // screen readers: a slider named after its control, with its value let label_text = crate::i18n::tr(label_override.unwrap_or(spec.label)).to_string(); resp.widget_info(|| egui::WidgetInfo::slider(enabled, value, label_text.clone())); diff --git a/crates/ui-egui/src/widgets/text_field.rs b/crates/ui-egui/src/widgets/text_field.rs index cb150080b..6b5c60c55 100644 --- a/crates/ui-egui/src/widgets/text_field.rs +++ b/crates/ui-egui/src/widgets/text_field.rs @@ -162,14 +162,10 @@ impl<'a> TextField<'a> { self } - /// Draw the field and return its response, any ending this frame, and whether an edit is - /// under way. Per-frame state keeps the pre-edit text for Esc, the selection across the - /// context menu, and select-on-focus; single-line Enter ends the edit, and focus loss ends it - /// except when the focus went to the field's own menu (a choice from the menu resumes the - /// edit). The menu offers cut/copy/paste/select-all (paste is omitted on the web; egui also - /// handles their keyboard shortcuts), and a field not drawn on the previous pass starts clean. - pub fn show(self, ui: &mut Ui) -> TextFieldResponse { - let id = id(self.widget); + /// The field's remembered state at the start of a pass: a field not drawn on the previous pass starts afresh, + /// a begun edit or a choice made from the menu last frame is carried out, and a field focused by the previous + /// pass selects its text when it asks to. + fn prepare(&self, ui: &mut Ui, id: egui::Id) -> Memo { let mut memo: Memo = ui.data_mut(|d| d.get_temp(id.with("memo"))).unwrap_or_default(); // a field not drawn last frame (its dialog or panel closed) starts afresh: an edit, a menu // or a choice from it that was under way is gone with it @@ -207,6 +203,19 @@ impl<'a> TextField<'a> { memo.before = Some(self.text.clone()); select_all(ui.ctx(), id, self.text); } + memo + } + + /// Draw the field and return its response, any ending this frame, and whether an edit is + /// under way. Per-frame state keeps the pre-edit text for Esc, the selection across the + /// context menu, and select-on-focus; single-line Enter ends the edit, and focus loss ends it + /// except when the focus went to the field's own menu (a choice from the menu resumes the + /// edit). The menu offers cut/copy/paste/select-all (paste is omitted on the web; egui also + /// handles their keyboard shortcuts), and a field not drawn on the previous pass starts clean. + pub fn show(self, ui: &mut Ui) -> TextFieldResponse { + let id = id(self.widget); + let pass = ui.ctx().cumulative_pass_nr(); + let mut memo = self.prepare(ui, id); let name = self.label.clone().or_else(|| self.hint.clone()); let mut edit = if self.multiline { egui::TextEdit::multiline(self.text) } else { egui::TextEdit::singleline(self.text) }; edit = edit.id(id); From 4ed9a5c86936708dc27c594f0113a69085413c9c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 03:20:48 +0000 Subject: [PATCH 34/39] refactor(ui-egui): split the grid cell and context menu --- crates/ui-egui/src/panels/grid.rs | 170 ++++++++++++++++------- crates/ui-egui/src/panels/left/albums.rs | 46 +++--- 2 files changed, 149 insertions(+), 67 deletions(-) diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 262979cc5..5064b8d40 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -770,7 +770,34 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square register(ui, format!("thumb:{}", id.0), r); let state = app.session.selection.state_of(id); let selected = state != lightcraft_engine::SelectionState::NotSelected; - // screen readers: the file, then rating / flag / label + let spoken = cell_description(app, &photo); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, &spoken)); + let active = state == lightcraft_engine::SelectionState::Active; + let p = ui.painter(); + let img_rect = if square { + let base = if selected { t.cell_selected } else { t.cell }; + p.rect_filled(r, 0.0, crate::theme::label_background(base, photo.label, selected)); + Rect::from_min_max(r.min + vec2(10.0, 24.0), r.max - vec2(10.0, 10.0)) + } else { + r + }; + paint_cell_thumbnail(app, p, &t, id, img_rect, ThumbFlags { square, selected, active, onscreen, ppp }); + // labels and badges + paint_cell_caption(app, p, &t, &photo, r, square); + paint_cell_badges(app, p, &t, &photo, img_rect, BadgeFlags { square, selected, hovered: resp.hovered() }); + if let Some(name) = &photo.copy_name { + paint_copy_tag(p, &t, name, img_rect, square); + } + if let Some(why) = &photo.preview_only { + paint_preview_pill(ui, p, &t, id, why, img_rect); + } + if photo.flag == Flag::Reject { + p.rect_filled(img_rect, 0.0, Color32::from_black_alpha(110)); + } + cell_interaction(app, ui, &resp, id, &photo); +} + +fn cell_description(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> String { let mut spoken = photo.file_name.clone(); if photo.rating > 0 { spoken.push_str(&crate::i18n::tr_format!(", {} star{}", photo.rating, if photo.rating == 1 { "" } else { "s" })); @@ -783,17 +810,11 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square if let Some(l) = photo.label { spoken.push_str(&crate::i18n::tr_format!(", {} label", crate::i18n::color_label(&app.session.catalog, l))); } - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, &spoken)); - let active = state == lightcraft_engine::SelectionState::Active; - let p = ui.painter(); - let img_rect = if square { - let base = if selected { t.cell_selected } else { t.cell }; - p.rect_filled(r, 0.0, crate::theme::label_background(base, photo.label, selected)); - Rect::from_min_max(r.min + vec2(10.0, 24.0), r.max - vec2(10.0, 10.0)) - } else { - r - }; - // thumbnail + spoken +} + +fn paint_cell_thumbnail(app: &mut LightcraftApp, p: &egui::Painter, t: &Tokens, id: PhotoId, img_rect: Rect, flags: ThumbFlags) { + let ThumbFlags { square, selected, active, onscreen, ppp } = flags; let size = thumb_px(img_rect.width().max(img_rect.height()), ppp); request_thumb(app, id, size, if onscreen { 10 } else { 5 }); if let Some(tex) = app.renderer.thumb(id) { @@ -822,7 +843,9 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square p.text(ph.center(), Align2::CENTER_CENTER, "!", t.semibold(18.0), t.text); } } - // labels and badges +} + +fn paint_cell_caption(app: &LightcraftApp, p: &egui::Painter, t: &Tokens, photo: &lightcraft_catalog::Photo, r: Rect, square: bool) { if square && app.ui.show_filenames { let m = &photo.meta; let name = match app.ui.grid_info.as_str() { @@ -850,8 +873,12 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square p.rect_filled(br, 2.0, Color32::from_gray(26)); p.galley(br.min + vec2(4.0, 1.5), g, t.text_label); } +} + +fn paint_cell_badges(app: &LightcraftApp, p: &egui::Painter, t: &Tokens, photo: &lightcraft_catalog::Photo, img_rect: Rect, flags: BadgeFlags) { + let BadgeFlags { square, selected, hovered } = flags; let show_badges = match app.ui.settings.grid_badges { - crate::state::GridBadges::Auto => resp.hovered() || selected || photo.rating > 0 || photo.flag != Flag::None || photo.label.is_some(), + crate::state::GridBadges::Auto => hovered || selected || photo.rating > 0 || photo.flag != Flag::None || photo.label.is_some(), crate::state::GridBadges::Always => true, crate::state::GridBadges::Never => false, }; @@ -863,7 +890,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square } if show_badges { let bar = badge_bar; - if (resp.hovered() || selected) && (square || photo.label.is_none()) { + if (hovered || selected) && (square || photo.label.is_none()) { p.rect_filled(bar, 0.0, Color32::from_black_alpha(120)); } let mut x = bar.left() + 6.0; @@ -882,40 +909,41 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square p.circle(pos2(right - 12.0, bar.center().y), 5.0, label_color(l), Stroke::new(1.0, Color32::from_black_alpha(120))); } } - if let Some(name) = &photo.copy_name { - // virtual copy: a folded-corner tag at the top right - let g = p.layout_no_wrap(name.clone(), t.semibold(10.5), Color32::WHITE); - let tr = if square { img_rect.right_top() + vec2(-6.0, 6.0) } else { img_rect.right_top() + vec2(-5.0, 5.0) }; - let br = Rect::from_min_max(pos2(tr.x - g.size().x - 26.0, tr.y), pos2(tr.x, tr.y + 20.0)); - p.rect_filled(br, 10.0, Color32::from_black_alpha(165)); - let c = pos2(br.left() + 13.0, br.center().y); - let s = 5.0; - p.add(egui::Shape::convex_polygon( - vec![c + vec2(-s, -s), c + vec2(s * 0.3, -s), c + vec2(s, -s * 0.3), c + vec2(s, s), c + vec2(-s, s)], - Color32::TRANSPARENT, - Stroke::new(1.2, Color32::WHITE), - )); - p.line_segment([c + vec2(s * 0.3, -s), c + vec2(s * 0.3, -s * 0.3)], Stroke::new(1.2, Color32::WHITE)); - p.line_segment([c + vec2(s * 0.3, -s * 0.3), c + vec2(s, -s * 0.3)], Stroke::new(1.2, Color32::WHITE)); - p.galley(pos2(br.left() + 22.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); - } - if let Some(why) = &photo.preview_only { - // a raw shown from its embedded JPEG: a small amber "Preview" pill at the image's bottom - // left, above the badge bar (always shown: it changes what editing does) - let g = p.layout_no_wrap(crate::i18n::tr("Preview").into(), t.semibold(9.5), Color32::WHITE); - let at = pos2(img_rect.left() + 6.0, img_rect.bottom() - 30.0 - 16.0); - let br = Rect::from_min_size(at, vec2(g.size().x + 24.0, 16.0)); - p.rect_filled(br, 8.0, Color32::from_black_alpha(170)); - paint(p, Rect::from_center_size(pos2(br.left() + 9.0, br.center().y), vec2(12.0, 12.0)), Icon::Info, t.caution); - p.galley(pos2(br.left() + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); - register(ui, format!("badge:previewOnly:{}", id.0), br); - ui.interact(br, egui::Id::unique(("preview-only-badge", id.0)), Sense::hover()) - .on_hover_text(crate::i18n::tr_format!("Preview only — {}", crate::widgets::preview_only_explanation(why))); - } - if photo.flag == Flag::Reject { - p.rect_filled(img_rect, 0.0, Color32::from_black_alpha(110)); - } - // interaction +} + +fn paint_copy_tag(p: &egui::Painter, t: &Tokens, name: &str, img_rect: Rect, square: bool) { + // virtual copy: a folded-corner tag at the top right + let g = p.layout_no_wrap(name.to_string(), t.semibold(10.5), Color32::WHITE); + let tr = if square { img_rect.right_top() + vec2(-6.0, 6.0) } else { img_rect.right_top() + vec2(-5.0, 5.0) }; + let br = Rect::from_min_max(pos2(tr.x - g.size().x - 26.0, tr.y), pos2(tr.x, tr.y + 20.0)); + p.rect_filled(br, 10.0, Color32::from_black_alpha(165)); + let c = pos2(br.left() + 13.0, br.center().y); + let s = 5.0; + p.add(egui::Shape::convex_polygon( + vec![c + vec2(-s, -s), c + vec2(s * 0.3, -s), c + vec2(s, -s * 0.3), c + vec2(s, s), c + vec2(-s, s)], + Color32::TRANSPARENT, + Stroke::new(1.2, Color32::WHITE), + )); + p.line_segment([c + vec2(s * 0.3, -s), c + vec2(s * 0.3, -s * 0.3)], Stroke::new(1.2, Color32::WHITE)); + p.line_segment([c + vec2(s * 0.3, -s * 0.3), c + vec2(s, -s * 0.3)], Stroke::new(1.2, Color32::WHITE)); + p.galley(pos2(br.left() + 22.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); +} + +fn paint_preview_pill(ui: &egui::Ui, p: &egui::Painter, t: &Tokens, id: PhotoId, why: &str, img_rect: Rect) { + // a raw shown from its embedded JPEG: a small amber "Preview" pill at the image's bottom + // left, above the badge bar (always shown: it changes what editing does) + let g = p.layout_no_wrap(crate::i18n::tr("Preview").into(), t.semibold(9.5), Color32::WHITE); + let at = pos2(img_rect.left() + 6.0, img_rect.bottom() - 30.0 - 16.0); + let br = Rect::from_min_size(at, vec2(g.size().x + 24.0, 16.0)); + p.rect_filled(br, 8.0, Color32::from_black_alpha(170)); + paint(p, Rect::from_center_size(pos2(br.left() + 9.0, br.center().y), vec2(12.0, 12.0)), Icon::Info, t.caution); + p.galley(pos2(br.left() + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); + register(ui, format!("badge:previewOnly:{}", id.0), br); + ui.interact(br, egui::Id::unique(("preview-only-badge", id.0)), Sense::hover()) + .on_hover_text(crate::i18n::tr_format!("Preview only — {}", crate::widgets::preview_only_explanation(why))); +} + +fn cell_interaction(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, id: PhotoId, photo: &lightcraft_catalog::Photo) { if let Some(k) = app.ui.keyword_painter.clone() { // painting: a click toggles the keyword on this photo if resp.hovered() { @@ -950,7 +978,21 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square } app.ui.dragging_photos = Some(app.session.selection.ids.iter().map(|p| p.0).collect()); } - crate::widgets::context_menu(&resp, |ui| context_menu(app, ui, id)); + crate::widgets::context_menu(resp, |ui| context_menu(app, ui, id)); +} + +struct ThumbFlags { + square: bool, + selected: bool, + active: bool, + onscreen: bool, + ppp: f32, +} + +struct BadgeFlags { + square: bool, + selected: bool, + hovered: bool, } /// The header of a Local folder view: the path as a breadcrumb (each part opens that folder), @@ -1215,6 +1257,16 @@ pub fn toggle_stack(app: &mut LightcraftApp, ctx: &egui::Context, id: PhotoId) { } pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + context_menu_open_and_edit(app, ui, id); + context_menu_rate(app, ui); + context_menu_albums(app, ui, id); + context_menu_file_actions(app, ui); + context_menu_stack_and_edits(app, ui, id); + context_menu_locate_and_export(app, ui, id); + context_menu_delete(app, ui); +} + +fn context_menu_open_and_edit(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if !app.session.selection.contains(id) { let _ = app.run("library.select", json!({"ids": [id.0]})); } @@ -1266,6 +1318,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { app.toast(ui.ctx(), e); } ui.separator(); +} + +fn context_menu_rate(app: &mut LightcraftApp, ui: &mut egui::Ui) { crate::widgets::menu_button(ui, crate::i18n::tr("Set Rating"), |ui| { for r in 0..=5 { if ui.button(if r == 0 { crate::i18n::tr("No Stars").to_string() } else { "★".repeat(r) }).clicked() { @@ -1281,6 +1336,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); crate::widgets::menu_button(ui, crate::i18n::tr("Set Color Label"), |ui| label_menu(app, ui)); +} + +fn context_menu_albums(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if local_only(app, &app.session.selected()) { // albums hold library photos (issue #684): nothing here to add until they join My Photos let add = ui.add_enabled(false, egui::Button::new(crate::i18n::tr("Add to Album"))).on_disabled_hover_text(albums_hint()); @@ -1323,6 +1381,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("album.setCover", json!({"id": aid.0, "photo": id.0})); } } +} + +fn context_menu_file_actions(app: &mut LightcraftApp, ui: &mut egui::Ui) { if ui.button(crate::i18n::tr("Rename…")).clicked() { let _ = app.run("dialog.rename", json!({})); } @@ -1354,6 +1415,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Create Version")).clicked() { let _ = app.run("version.create", json!({})); } +} + +fn context_menu_stack_and_edits(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { crate::widgets::menu_button(ui, crate::i18n::tr("Stack"), |ui| { let stacked = app.session.catalog.stack_of(id).is_some(); let several = app.session.selection.ids.len() > 1; @@ -1405,6 +1469,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("photo.rotateRight", json!({})); } ui.separator(); +} + +fn context_menu_locate_and_export(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // the original moved or its drive is gone: point the photo at the file again // (a cached answer, checked off the UI thread) let missing = matches!(&app.session.catalog.photo(id).map(|p| p.source.clone()), Some(lightcraft_catalog::Source::File { path }) if app.session.media.availability.is_offline(path)); @@ -1437,6 +1504,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); ui.separator(); +} + +fn context_menu_delete(app: &mut LightcraftApp, ui: &mut egui::Ui) { if crate::menubar::selection_deleted(app) { if ui.button(crate::i18n::tr("Restore")).clicked() { let _ = app.run("photo.restore", json!({})); diff --git a/crates/ui-egui/src/panels/left/albums.rs b/crates/ui-egui/src/panels/left/albums.rs index 5237f69b8..92498eb02 100644 --- a/crates/ui-egui/src/panels/left/albums.rs +++ b/crates/ui-egui/src/panels/left/albums.rs @@ -397,23 +397,35 @@ fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response let t = Tokens::get(ui.ctx()); ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); if released { - match app.run("album.addPhotos", json!({"id": a.id.0, "ids": ids})) { - Ok(r) => { - let n = r.get("added").and_then(serde_json::Value::as_u64).unwrap_or(0); - let skipped = r.get("skipped").and_then(serde_json::Value::as_u64).unwrap_or(0); - let mut msg = crate::i18n::tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, a.name, n = n); - if skipped > 0 { - msg.push_str(" · "); - msg.push_str(&crate::i18n::tr_format!( - "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)", - if skipped == 1 { "" } else { "s" }, - n = skipped - )); - } - app.toast(ui.ctx(), msg); - } - Err(e) => app.toast(ui.ctx(), e), - } + drop_photos_on_album(app, ui, a, ids); app.ui.dragging_photos = None; } } + +/// Adds the dragged photos to the album and toasts how many went in (and how many Local ones were skipped). +fn drop_photos_on_album(app: &mut LightcraftApp, ui: &egui::Ui, a: &Album, ids: Vec) { + match app.run("album.addPhotos", json!({"id": a.id.0, "ids": ids})) { + Ok(r) => app.toast(ui.ctx(), added_message(a, &r)), + Err(e) => app.toast(ui.ctx(), e), + } +} + +/// "Added N photos to “album”", plus a note when Local photos were skipped. +fn added_message(a: &Album, r: &serde_json::Value) -> String { + let n = r.get("added").and_then(serde_json::Value::as_u64).unwrap_or(0); + let skipped = r.get("skipped").and_then(serde_json::Value::as_u64).unwrap_or(0); + let mut msg = crate::i18n::tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, a.name, n = n); + if skipped > 0 { + msg.push_str(" · "); + msg.push_str(&skipped_note(skipped)); + } + msg +} + +fn skipped_note(skipped: u64) -> String { + crate::i18n::tr_format!( + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)", + if skipped == 1 { "" } else { "s" }, + n = skipped + ) +} From f858578f8c014104cdf3a9f5b468fdccd20e020e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 03:20:48 +0000 Subject: [PATCH 35/39] refactor(ui-egui): split the detail view, mask overlay and denoise panel --- crates/ui-egui/src/panels/denoise.rs | 64 +- crates/ui-egui/src/panels/detail.rs | 921 ++++++++++++++-------- crates/ui-egui/src/panels/detail/masks.rs | 332 ++++---- 3 files changed, 802 insertions(+), 515 deletions(-) diff --git a/crates/ui-egui/src/panels/denoise.rs b/crates/ui-egui/src/panels/denoise.rs index fdb208b51..b72df10b2 100644 --- a/crates/ui-egui/src/panels/denoise.rs +++ b/crates/ui-egui/src/panels/denoise.rs @@ -319,6 +319,13 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V if status["enabled"] != true { return; } + speed_section(app, ui, t, list, &status); + photos_section(app, ui, t, list, &status); + cache_section(app, ui, t, list, &status); +} + +/// Where the work runs. +fn speed_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &Value, status: &Value) { heading(ui, t, "Speed"); let current = list["runOn"].as_str().unwrap_or("auto").to_string(); let mut run_on = current.as_str(); @@ -342,6 +349,10 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V app.caches.denoise.epoch += 1; } } +} + +/// Whether photos are made as they are looked at, and the progress of what is being made. +fn photos_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &Value, status: &Value) { heading(ui, t, "Photos"); let mut auto = list["auto"].as_bool().unwrap_or(true); if check(ui, "settings.denoiseAuto", &mut auto, "Make the denoised picture of the photos I am looking at") { @@ -350,27 +361,7 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V } hint(ui, t, "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing."); let (running, queued) = (app.caches.denoise.running, app.caches.denoise.queued); - match running { - Some((done, total)) => { - let name = status["running"]["photo"] - .as_u64() - .and_then(|p| app.session.catalog.photo(PhotoId(p)).map(|p| p.file_name.clone())) - .unwrap_or_default(); - let text = tr_format!( - "Denoising {name}: {done} of {total} tiles{}", - if queued > 0 { tr_format!(" · {queued} waiting", queued = queued) } else { String::new() }, - name = name, - done = done, - total = total - ); - settings_bar(ui, done, total, text); - } - None if queued > 0 => hint(ui, t, &count(queued, "photo waiting", "photos waiting")), - None => { - let ready = status["ready"].as_u64().unwrap_or(0); - hint(ui, t, &count(ready, "photo has its denoised picture.", "photos have their denoised picture.")) - } - } + progress_line(app, ui, t, status, running, queued); ui.horizontal(|ui| { let r = ui.button(tr("Denoise all photos with an Amount")); register(ui, "denoise:queueAll", r.rect); @@ -385,6 +376,37 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V } } }); +} + +/// "Denoising {photo}: {done} of {total} tiles", and how many photos wait. +fn running_text(app: &LightcraftApp, status: &Value, done: u64, total: u64, queued: u64) -> String { + let name = + status["running"]["photo"].as_u64().and_then(|p| app.session.catalog.photo(PhotoId(p)).map(|p| p.file_name.clone())).unwrap_or_default(); + tr_format!("Denoising {name}: {done} of {total} tiles{}", waiting_suffix(queued), name = name, done = done, total = total) +} + +/// " · N waiting" after the running photo, when any wait. +fn waiting_suffix(queued: u64) -> String { + if queued > 0 { tr_format!(" · {queued} waiting", queued = queued) } else { String::new() } +} + +/// What is being made now, or how many photos wait or are done. +fn progress_line(app: &LightcraftApp, ui: &mut egui::Ui, t: &Tokens, status: &Value, running: Option<(u64, u64)>, queued: u64) { + match running { + Some((done, total)) => { + let text = running_text(app, status, done, total, queued); + settings_bar(ui, done, total, text); + } + None if queued > 0 => hint(ui, t, &count(queued, "photo waiting", "photos waiting")), + None => { + let ready = status["ready"].as_u64().unwrap_or(0); + hint(ui, t, &count(ready, "photo has its denoised picture.", "photos have their denoised picture.")) + } + } +} + +/// The cache's contents and limit. +fn cache_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &Value, status: &Value) { heading(ui, t, "Cache"); let (files, bytes) = (status["cache"]["files"].as_u64().unwrap_or(0), status["cache"]["bytes"].as_u64().unwrap_or(0)); hint( diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 3eb94779c..2b58595d2 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -138,11 +138,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { filmstrip(app, ui, Rect::from_min_max(pos2(full.left(), canvas.bottom()), full.max)); } let Some(id) = app.session.active() else { - if super::empty_library::shown(&app.session) { - super::empty_library::show(app, ui, canvas); - } else { - super::empty_message(ui, canvas, "No photo selected", "Choose a photo in the grid or filmstrip"); - } + show_no_photo(app, ui, canvas); return; }; let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; @@ -154,47 +150,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &d, !crop_tool, photo.embedded_lens.as_ref()); let aspect = frame.aspect() as f32; let ppp = ui.ctx().pixels_per_point(); - let area = canvas.shrink(if fullscreen { - 0.0 - } else if crop_tool { - 48.0 - } else { - 24.0 - }); + let area = canvas.shrink(loupe_margin(fullscreen, crop_tool)); let native = output_px(&frame); let texture_side = texture_side(ui.ctx()); // two views (before, after): side by side or stacked let split = matches!(app.ui.before_after, BeforeAfter::SideBySide | BeforeAfter::TopBottom); let split_view = matches!(app.ui.before_after, BeforeAfter::Split | BeforeAfter::SplitTopBottom); - let areas: Vec = match app.ui.before_after { - BeforeAfter::SideBySide => { - let half = area.width() / 2.0 - 6.0; - vec![ - Rect::from_min_size(area.min, vec2(half, area.height())), - Rect::from_min_size(pos2(area.center().x + 6.0, area.top()), vec2(half, area.height())), - ] - } - BeforeAfter::TopBottom => { - let half = area.height() / 2.0 - 14.0; - vec![ - Rect::from_min_size(area.min, vec2(area.width(), half)), - Rect::from_min_size(pos2(area.left(), area.center().y + 14.0), vec2(area.width(), half)), - ] - } - _ => vec![area], - }; + let areas = view_areas(app.ui.before_after, area); let main_area = *areas.last().unwrap_or(&area); - // A previous crop/rotation can stay visible while its replacement renders. Its pixels - // must not override the current frame geometry, even when it is the same photo. - let current_texture = |t: &&crate::render::Tex| t.photo == id && t.look == Some((crate::render::geometry_look(&d), !crop_tool)); - let visible_texture_aspect = app - .renderer - .textures - .get(&Slot::Main) - .filter(current_texture) - .or_else(|| app.renderer.textures.get(&Slot::Preview).filter(current_texture)) - .or_else(|| app.renderer.thumb(id).filter(current_texture)) - .map(|tex| tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32); + let visible_texture_aspect = visible_texture_aspect(app, id, &d, crop_tool); // Follow the texture only where it really differs (an unsupported raw's embedded JPEG with // another crop): a texture's whole-pixel size is a little off the exact aspect, and the crop // tool's overlays need the frame's own geometry. @@ -204,15 +168,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let mut target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan); let mut img_rect = animated_rect(ui.ctx(), &mut app.ui.zoom_anim, target_rect); let in_pane = |pane: Rect, image: Rect| image.translate(pane.center() - main_area.center()); - let mut navigating = false; - for &view_area in &areas { + let navigating = areas.iter().any(|&view_area| { let view_img = in_pane(view_area, img_rect); - if navigate_gesture(app, ui, &resp, view_area, view_img, native) || middle_pan(app, ui, &resp, view_area, view_img) { - navigating = true; - target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan); - img_rect = target_rect; - break; - } + navigate_gesture(app, ui, &resp, view_area, view_img, native) || middle_pan(app, ui, &resp, view_area, view_img) + }); + if navigating { + target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan); + img_rect = target_rect; } let before_img = in_pane(areas[0], img_rect); let before_target = in_pane(areas[0], target_rect); @@ -222,14 +184,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let overlay = view_overlay(app, &d); // 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 - && lightcraft_engine::gpu::handles( - lightcraft_pipeline::OutputDepth::U8, - app.ui.soft_proof && lightcraft_pipeline::proof_profile(app.ui.proof.profile).is_some(), - overlay, - ) - && lightcraft_engine::gpu::ready() - && lightcraft_engine::gpu::available(); + let gpu = interacting && gpu_draws_view(app, overlay); 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 now = ui.input(|i| i.time); @@ -250,10 +205,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { draft, windows: overlay_supports_window(overlay), }; - let mut plan = crate::region::plan(&app.ui.settings, sizes); - if plan.window_edge.is_some_and(|edge| app.window_refused == Some((id, look, edge))) { - plan = crate::region::plan(&app.ui.settings, crate::region::ViewSizes { windows: false, ..sizes }); - } + let plan = loupe_plan(app, id, look, 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()) { @@ -271,14 +223,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // 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)) - }; + let want = whole_frame_edge(app, interacting, &plan, native_long, 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. @@ -287,72 +232,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // 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, 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(fit_px[0], fit_px[1]) }; - 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); - // a drag's draft that renders in a few ms (GPU) is drawn in this frame, not the next (zoomed - // in, what changes on screen is the window's draft) - #[cfg(not(target_arch = "wasm32"))] - if interacting && plan.window_edge.is_none() { - let frame = ui.input(|i| i.predicted_dt).clamp(1.0 / 240.0, 1.0 / 30.0); - // (a frame time that is not a number waits for nothing) - let budget = std::time::Duration::try_from_secs_f32(frame * 0.4).unwrap_or_default(); - app.renderer.wait_for_draft(ui.ctx(), &mut app.session, budget); - } - } + let ask = LoupeAsk { id, with_crop: !crop_tool, interacting, fit_px, overlay }; + request_main(app, ui, &ask, [rw, rh], plan.window_edge.is_none()); // hovering a preset or profile: the photo with that look, shown instead of the loupe render // 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(fit_px[0], fit_px[1]); - let key = job.key; - app.renderer.request(Slot::Hover, job, 105); - (key, h.label) - }), - _ => None, - }; - // once this photo is on screen: prepare its neighbours in filmstrip order (source decoded and - // kept, view render cached) so stepping to them is instant - if !interacting && !app.renderer.is_pending(Slot::Main) && app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.photo == id) { - let ids = app.session.visible_cloned(); - if let Some(i) = ids.iter().position(|p| *p == id) { - let next = ids.get(i + 1).copied(); - let prev = i.checked_sub(1).and_then(|j| ids.get(j)).copied(); - for (n, nid) in [next, prev].into_iter().enumerate() { - let Some(nid) = nid else { continue }; - let Some(np) = app.session.catalog.photo(nid).cloned() else { continue }; - let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, !crop_tool, np.embedded_lens.as_ref()); - let na = nf.aspect() as f32; - let nr = fit_rect(main_area, na, app.ui.zoom, output_px(&nf), ppp, app.ui.pan); - let nw = - app.ui.settings.prefetch_edge(nr.width().max(nr.height()) * ppp, output_px(&nf).into_iter().max().unwrap_or(1), texture_side); - let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) }; - if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), !crop_tool) { - app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY); - } - } - } + let hover_key = request_hover(app, &ask, [sw, sh]); + if !interacting { + prefetch_neighbours(app, id, main_area, ppp, texture_side, !crop_tool); } // until the loupe has this photo: show its cached render / embedded preview / a thumbnail - if app.renderer.textures.get(&Slot::Main).is_none_or(|t| t.photo != id) - && let Some(q) = app.session.quick_view_job(id, want.max(8), !crop_tool) - { - app.renderer.request_quick(Slot::Preview, q, 110); - } + request_stand_in(app, id, want, !crop_tool); let show_before = app.ui.before_after == BeforeAfter::Original || ui.input(|i| i.key_down(egui::Key::Backslash)); - 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(fit_px[0], fit_px[1]) }; - app.renderer.request(Slot::Before, job, 90); + if split || show_before || split_view { + request_before(app, &ask, [sw, sh]); } // zoomed past what the whole-frame render holds: also render the part on screen, at no more // than 100 % (the GPU magnifies beyond that), for each side that is shown. Mask overlays and @@ -371,63 +263,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { look, holding: navigating || app.size_hold.holding(now), }; - // (slot, before side?, rect to draw in, rect it is requested for, area whose pixels show) - let mut window_views: Vec = Vec::new(); - if window_ctx.frame_edge.is_some() { - let after = WindowView { slot: Slot::Region, before: false, img: img_rect, target: target_rect, visible: main_area }; - if split { - window_views.push(WindowView { slot: Slot::RegionBefore, before: true, img: before_img, target: before_target, visible: areas[0] }); - window_views.push(after); - } else if show_before { - window_views.push(WindowView { slot: Slot::RegionBefore, before: true, img: img_rect, target: target_rect, visible: canvas }); - } else if split_view { - window_views.push(WindowView { slot: Slot::RegionBefore, before: true, img: img_rect, target: target_rect, visible: canvas }); - window_views.push(WindowView { visible: canvas, ..after }); - } else { - window_views.push(WindowView { visible: canvas, ..after }); - } - } - let mut windows: Vec<(WindowView, crate::region::RegionView)> = Vec::new(); - for v in window_views { - if let Some(view) = request_window(app, &window_ctx, &v) { - windows.push((v, view)); - } - } - for (slot, before) in [(Slot::Region, false), (Slot::RegionBefore, true)] { - let shown = windows.iter().find(|(v, _)| v.before == before).map(|(_, view)| *view); - if before { - app.region_before_view = shown; - } else { - app.region_view = shown; - } - if shown.is_none() { - app.region_tiles.retain(|(b, _), _| *b != before); - app.renderer.release(slot); - } - } - let p = ui.painter_at(canvas); - // what a view slot shows: its own render of this photo, else the stand-ins (no blank frame - // between photos, and the full render replaces them in place) - let draw = |slot: Slot, r: Rect, clip: Rect| -> &'static str { - let mine = |s: Slot| app.renderer.textures.get(&s).filter(|t| t.photo == id); - let (tex, what) = if let Some(t) = mine(slot) { - (t, "render") - } else if let Some(t) = mine(Slot::Preview) { - (t, t.quick.map(quick_name).unwrap_or("preview")) - } else if let Some(t) = app.renderer.thumb(id) { - (t, "thumb") - } else { - return "none"; - }; - 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 + let window_views = if window_ctx.frame_edge.is_some() { + let mode = ViewMode { split, show_before, split_view }; + window_views(mode, &Views { img_rect, target_rect, before_img, before_target, areas: &areas, main_area, canvas }) + } else { + Vec::new() }; + let windows = request_windows(app, &window_ctx, window_views); + let p = ui.painter_at(canvas); + let draw = |slot: Slot, r: Rect, clip: Rect| draw_slot(app, &p, id, ppp, slot, r, clip); // soft proofing: a paper-white surround and the proof's name, as Lightroom shows it if app.ui.soft_proof && !fullscreen { - p.rect_filled(canvas, 0.0, Color32::from_gray(238)); - let label = crate::i18n::tr_format!("Proof Preview · {}", app.ui.proof.space.label()); - p.text(pos2(canvas.right() - 16.0, canvas.top() + 14.0), Align2::RIGHT_CENTER, label, t.font(12.5), Color32::from_gray(60)); + proof_surround(app, &p, &t, canvas); } let shown; // the Before label's pill, painted once the window renders are down (they would cover it) @@ -441,10 +288,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let br = before_img; draw(Slot::Before, br, areas[0]); shown = draw(Slot::Main, img_rect, main_area); - // the labels under each photo, or under its pane when the photo reaches past it - let label_at = |r: Rect, pane: Rect| pos2(r.left().max(pane.left()), r.bottom().min(pane.bottom()) + 14.0); - p.text(label_at(br, areas[0]), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text_dim); - p.text(label_at(img_rect, main_area), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim); + split_labels(&p, &t, [(br, areas[0]), (img_rect, main_area)]); } else if show_before { shown = draw(Slot::Before, img_rect, canvas); // on the photo itself, so it gets the hover label's dark pill to stay readable over light @@ -454,37 +298,136 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } 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 = label_badge_rect(hover_rect, canvas, g.size(), badge_top_inset); - p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); - p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); + draw_hover(&p, &t, tex, label, [img_rect, canvas], badge_top_inset); shown = "hover"; } else { shown = draw(Slot::Main, img_rect, canvas); if shown == "none" { - match app.renderer.failure_of(Slot::Main, id) { - Some(e) => { - p.text( - canvas.center() - vec2(0.0, 10.0), - Align2::CENTER_CENTER, - crate::i18n::tr("This photo can't be opened"), - t.semibold(14.0), - t.text, - ); - p.text(canvas.center() + vec2(0.0, 12.0), Align2::CENTER_CENTER, e, t.font(12.0), t.text_dim); - } - None if app.ui.settings.loupe_messages => { - p.text(canvas.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(13.0), t.text_dim); - } - None => {} - } + none_message(app, &p, &t, canvas, id); } } app.loupe_shown = Some((id, shown)); - // a wipe shows the Before whole-frame render on its side of the line… let line = crate::region::wipe_line(img_rect, canvas); + draw_wipe_before(app, &p, id, img_rect, line); + // …and the window renders go over the whole-frame ones, all of them, where they belong in the + // frame (clipped to their pane, or their side of the line) + for (v, view) in &windows { + // (holding `\` in a wipe shows the Before everywhere: it is the Before view alone then) + let mode = if show_before { BeforeAfter::Original } else { app.ui.before_after }; + let clip = crate::region::window_clip(mode, v.before, v.visible, canvas, img_rect); + draw_window(&p.with_clip_rect(clip), app, &window_ctx, v, view); + } + if let Some((bg, g)) = before_badge { + paint_badge(&p, bg, g); + register(ui, "canvas:beforeLabel", bg); + } + // the line of a wipe over both + draw_wipe_line(app.ui.before_after, &p, img_rect, line); + let map = CanvasMap::new(&frame, img_rect); + let over = OverPhoto { photo: &photo, d: &d, map: &map, canvas, img_rect, fullscreen, crop_tool, clipping: !show_before }; + photo_overlays(app, ui, &p, &over); + // Cursor selection must also run on gesture frames, when tool input is suppressed. + if !matches!(right, RightPanel::Crop | RightPanel::Masking | RightPanel::Remove | RightPanel::RedEye) { + general_cursor(app, ui, &resp, img_rect, main_area); + } + if !navigating { + let tv = ToolView { right, map: &map, frame: &frame, d: &d, id, split, native, aspect }; + tool_interaction(app, ui, &resp, &tv, &Views { img_rect, target_rect, before_img, before_target, areas: &areas, main_area, canvas }); + } + // drawn and hit-tested above the loupe and its tools: clicks on it pan + let nav = navigator(app, ui, canvas, img_rect, id); + // what is on screen isn't the render on its way (a stand-in, the last render after an edit, + // a magnified frame under its window): say so once that has lasted a moment + let coming = render_coming(app, shown, &windows, ViewMode { split, show_before, split_view }); + let keys = + [Slot::Main, Slot::Before, Slot::Region, Slot::RegionBefore].map(|s| app.renderer.textures.get(&s).filter(|t| t.photo == id).map(|t| t.key)); + let rendering = ui.ctx().data_mut(|d| rendering_overdue(d.get_temp_mut_or(egui::Id::unique("loupe-rendering"), None), keys, coming, now)); + // the render failed while a stand-in is up: what is on screen is all there will be (also while + // a library change retries it) + let failed = failed_render(app, id, !rendering && (!show_before || split), shown); + if rendering || failed.is_some() { + let over = if crop_tool { canvas } else { img_rect.intersect(if split { main_area } else { canvas }) }; + let over = if over.is_positive() { over } else { canvas }; + status_pill(ui, app, &photo, [canvas, over], nav, failed); + } + if !fullscreen { + photo.preview_only.as_deref().inspect(|why| preview_only_pill(ui, canvas, why)); + } + loupe_context_menu(app, &resp, id); +} + +/// The loupe's right-click menu: zoom commands and the grid's photo menu. +fn loupe_context_menu(app: &mut LightcraftApp, resp: &egui::Response, id: PhotoId) { + crate::widgets::context_menu(resp, |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Zoom"), |ui| { + for (label, cmd) in [("Fit", "view.zoomFit"), ("100%", "view.zoom100"), ("Zoom In", "view.zoomIn"), ("Zoom Out", "view.zoomOut")] { + if ui.button(crate::i18n::tr(label)).clicked() { + let _ = app.run(cmd, json!({})); + } + } + }); + ui.separator(); + super::grid::context_menu(app, ui, id); + }); +} + +/// A fine grid while a transform (geometry) slider is dragged, to judge verticals. +fn transform_grid(app: &LightcraftApp, p: &egui::Painter, img_rect: Rect) { + if app.ui.dragging_control.as_deref().is_some_and(|c| c.starts_with("geometry.")) { + let n = 12; + let stroke = Stroke::new(1.0, Color32::from_white_alpha(70)); + for i in 1..n { + let fx = img_rect.left() + img_rect.width() * i as f32 / n as f32; + p.line_segment([pos2(fx, img_rect.top()), pos2(fx, img_rect.bottom())], stroke); + } + let rows = ((n as f32) * img_rect.height() / img_rect.width()).round().max(2.0) as usize; + for i in 1..rows { + let fy = img_rect.top() + img_rect.height() * i as f32 / rows as f32; + p.line_segment([pos2(img_rect.left(), fy), pos2(img_rect.right(), fy)], stroke); + } + } +} + +/// The photo's face boxes and the name box on them. +fn face_boxes( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + map: &CanvasMap, + photo: &lightcraft_catalog::Photo, + d: &DevelopSettings, +) { + let id = photo.id; + // a name box left open on another photo is dropped + if app.ui.name_edit.as_ref().is_some_and(|e| e.photo != id.0) { + app.ui.name_edit = None; + } + let hints = super::faces::hints_for(app, id.0); + let people = if app.ui.name_edit.is_some() { app.caches.person_names(&app.session.catalog) } else { Default::default() }; + // while the name box is open and recognition is not set up, the box offers the next step + let setup = if app.ui.name_edit.is_some() { super::faces::setup(app, ui.ctx()) } else { super::faces::Setup::Running }; + let regions = RegionContext { photo, orient: d.orientation, hints: hints.as_deref(), people: &people, setup }; + match region_overlay(ui, p, map, ®ions, &mut app.ui.name_edit) { + Some(RegionEdit::SetUp) => { + app.ui.name_edit = None; + super::faces::take_step(app, ui.ctx(), setup); + } + Some(RegionEdit::Remove(index)) => { + let _ = app.run("photo.removeRegion", json!({"id": id.0, "index": index})); + } + Some(RegionEdit::Name(index, name)) => { + app.ui.name_edit = None; + let _ = app.run("faces.setName", json!({"id": id.0, "index": index, "name": name})); + } + Some(RegionEdit::Resize(index, r)) => { + let _ = app.run("photo.setRegion", json!({"id": id.0, "index": index, "rect": {"x0": r.x0, "y0": r.y0, "x1": r.x1, "y1": r.y1}})); + } + None => {} + } +} + +/// A wipe shows the Before whole-frame render on its side of the line. +fn draw_wipe_before(app: &LightcraftApp, p: &egui::Painter, id: PhotoId, img_rect: Rect, line: Pos2) { if app.ui.before_after == BeforeAfter::Split && let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) { @@ -501,157 +444,437 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let top = Rect::from_min_max(before_rect.min, pos2(before_rect.right(), before_rect.top() + v * before_rect.height())); p.image(tex.tex.id(), top, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, v)), Color32::WHITE); } - // …and the window renders go over the whole-frame ones, all of them, where they belong in the - // frame (clipped to their pane, or their side of the line) - for (v, view) in &windows { - // (holding `\` in a wipe shows the Before everywhere: it is the Before view alone then) - let mode = if show_before { BeforeAfter::Original } else { app.ui.before_after }; - let clip = crate::region::window_clip(mode, v.before, v.visible, canvas, img_rect); - draw_window(&p.with_clip_rect(clip), app, &window_ctx, v, view); +} + +/// Once a photo is on screen, prepare its neighbours in filmstrip order (source decoded and kept, +/// view render cached) so stepping to them is instant. +fn prefetch_neighbours(app: &mut LightcraftApp, id: PhotoId, main_area: Rect, ppp: f32, texture_side: usize, with_crop: bool) { + // only once this photo is on screen + if app.renderer.is_pending(Slot::Main) || !app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.photo == id) { + return; } - if let Some((bg, g)) = before_badge { - p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); - p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); - register(ui, "canvas:beforeLabel", bg); - } - // the line of a wipe over both - if app.ui.before_after == BeforeAfter::Split { - p.line_segment([pos2(line.x, img_rect.top()), pos2(line.x, img_rect.bottom())], Stroke::new(1.5, Color32::WHITE)); - } - if app.ui.before_after == BeforeAfter::SplitTopBottom { - p.line_segment([pos2(img_rect.left(), line.y), pos2(img_rect.right(), line.y)], Stroke::new(1.5, Color32::WHITE)); - } - if app.ui.show_clipping && !show_before && !fullscreen { - clipping_overlay(app, &p, img_rect); - } - // View ▸ Loupe Overlay ▸ Layout Image, over the photo (the After view in a before/after) - super::layout_overlay::draw(app, &p, img_rect); - register(ui, "canvas:image", img_rect); - let map = CanvasMap::new(&frame, img_rect); - info_overlay(app, &p, canvas, &photo); - if !fullscreen { - filter_pill(app, ui, canvas); - } - if app.ui.face_boxes { - // a name box left open on another photo is dropped - if app.ui.name_edit.as_ref().is_some_and(|e| e.photo != id.0) { - app.ui.name_edit = None; - } - let hints = super::faces::hints_for(app, id.0); - let people = if app.ui.name_edit.is_some() { app.caches.person_names(&app.session.catalog) } else { Default::default() }; - // while the name box is open and recognition is not set up, the box offers the next step - let setup = if app.ui.name_edit.is_some() { super::faces::setup(app, ui.ctx()) } else { super::faces::Setup::Running }; - let regions = RegionContext { photo: &photo, orient: d.orientation, hints: hints.as_deref(), people: &people, setup }; - match region_overlay(ui, &p, &map, ®ions, &mut app.ui.name_edit) { - Some(RegionEdit::SetUp) => { - app.ui.name_edit = None; - super::faces::take_step(app, ui.ctx(), setup); - } - Some(RegionEdit::Remove(index)) => { - let _ = app.run("photo.removeRegion", json!({"id": id.0, "index": index})); - } - Some(RegionEdit::Name(index, name)) => { - app.ui.name_edit = None; - let _ = app.run("faces.setName", json!({"id": id.0, "index": index, "name": name})); - } - Some(RegionEdit::Resize(index, r)) => { - let _ = app.run("photo.setRegion", json!({"id": id.0, "index": index, "rect": {"x0": r.x0, "y0": r.y0, "x1": r.x1, "y1": r.y1}})); - } - None => {} - } - } - if app.ui.settings.focus_point && !crop_tool { - draw_focus_point(&p.with_clip_rect(img_rect.intersect(canvas)), img_rect, &photo, "canvas:focusPoint"); - } - // a fine grid while a transform (geometry) slider is dragged, to judge verticals - if app.ui.dragging_control.as_deref().is_some_and(|c| c.starts_with("geometry.")) { - let n = 12; - let stroke = Stroke::new(1.0, Color32::from_white_alpha(70)); - for i in 1..n { - let fx = img_rect.left() + img_rect.width() * i as f32 / n as f32; - p.line_segment([pos2(fx, img_rect.top()), pos2(fx, img_rect.bottom())], stroke); - } - let rows = ((n as f32) * img_rect.height() / img_rect.width()).round().max(2.0) as usize; - for i in 1..rows { - let fy = img_rect.top() + img_rect.height() * i as f32 / rows as f32; - p.line_segment([pos2(img_rect.left(), fy), pos2(img_rect.right(), fy)], stroke); - } - } - // Cursor selection must also run on gesture frames, when tool input is suppressed. - if !matches!(right, RightPanel::Crop | RightPanel::Masking | RightPanel::Remove | RightPanel::RedEye) { - general_cursor(app, ui, &resp, img_rect, main_area); - } - if !navigating { - match right { - RightPanel::Crop => crop_overlay(app, ui, &resp, &map, &frame, &d, id), - RightPanel::Masking => mask_overlay(app, ui, &resp, &map, &d), - RightPanel::Remove => remove_overlay(app, ui, &resp, &map, &d), - RightPanel::RedEye => eye_overlay(app, ui, &resp, &map, &d), - _ => { - // side by side / stacked: Before shows the same frame, so a click in its view (white - // balance, colour samples, targeted adjustment) means the same point of the photo. - // Zoomed in, a view's image reaches past its pane: what counts is the pane clicked. - // A click-zoom there centres both views on the point clicked (#1742). - let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); - let (map, img, pane) = match (split, areas.first(), pointer) { - (true, Some(before), Some(q)) if before.contains(q) => (CanvasMap::new(&frame, before_img), before_img, *before), - _ => (map, img_rect, main_area), - }; - general_interaction(app, ui, &resp, &map, &PhotoPlacement { img, canvas: pane, native, aspect }) + let ids = app.session.visible_cloned(); + if let Some(i) = ids.iter().position(|p| *p == id) { + let next = ids.get(i + 1).copied(); + let prev = i.checked_sub(1).and_then(|j| ids.get(j)).copied(); + for (n, nid) in [next, prev].into_iter().enumerate() { + let Some(nid) = nid else { continue }; + let Some(np) = app.session.catalog.photo(nid).cloned() else { continue }; + let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, with_crop, np.embedded_lens.as_ref()); + let na = nf.aspect() as f32; + let nr = fit_rect(main_area, na, app.ui.zoom, output_px(&nf), ppp, app.ui.pan); + let nw = app.ui.settings.prefetch_edge(nr.width().max(nr.height()) * ppp, output_px(&nf).into_iter().max().unwrap_or(1), texture_side); + let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) }; + if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), with_crop) { + app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY); } } } - // drawn and hit-tested above the loupe and its tools: clicks on it pan - let nav = navigator(app, ui, canvas, img_rect, id); - // what is on screen isn't the render on its way (a stand-in, the last render after an edit, - // a magnified frame under its window): say so once that has lasted a moment - let whole: &[Slot] = if show_before && !split { +} + +/// The rects of the two views of a before/after (side by side or stacked), else the one area. +fn view_areas(mode: BeforeAfter, area: Rect) -> Vec { + match mode { + BeforeAfter::SideBySide => { + let half = area.width() / 2.0 - 6.0; + vec![ + Rect::from_min_size(area.min, vec2(half, area.height())), + Rect::from_min_size(pos2(area.center().x + 6.0, area.top()), vec2(half, area.height())), + ] + } + BeforeAfter::TopBottom => { + let half = area.height() / 2.0 - 14.0; + vec![ + Rect::from_min_size(area.min, vec2(area.width(), half)), + Rect::from_min_size(pos2(area.left(), area.center().y + 14.0), vec2(area.width(), half)), + ] + } + _ => vec![area], + } +} + +/// Which before/after layout is up. +#[derive(Clone, Copy)] +struct ViewMode { + split: bool, + show_before: bool, + split_view: bool, +} + +/// The rects the loupe's views are drawn in. +struct Views<'a> { + img_rect: Rect, + target_rect: Rect, + before_img: Rect, + before_target: Rect, + areas: &'a [Rect], + main_area: Rect, + canvas: Rect, +} + +/// The window renders wanted for the layout: (slot, before side?, rect to draw in, rect it is +/// requested for, area whose pixels show). +fn window_views(mode: ViewMode, v: &Views) -> Vec { + let mut out: Vec = Vec::new(); + let after = WindowView { slot: Slot::Region, before: false, img: v.img_rect, target: v.target_rect, visible: v.main_area }; + if mode.split { + out.push(WindowView { + slot: Slot::RegionBefore, + before: true, + img: v.before_img, + target: v.before_target, + visible: v.areas.first().copied().unwrap_or(v.canvas), + }); + out.push(after); + } else if mode.show_before { + out.push(WindowView { slot: Slot::RegionBefore, before: true, img: v.img_rect, target: v.target_rect, visible: v.canvas }); + } else if mode.split_view { + out.push(WindowView { slot: Slot::RegionBefore, before: true, img: v.img_rect, target: v.target_rect, visible: v.canvas }); + out.push(WindowView { visible: v.canvas, ..after }); + } else { + out.push(WindowView { visible: v.canvas, ..after }); + } + out +} + +/// What the canvas says when nothing could be drawn: why the photo failed, or that it is rendering. +fn none_message(app: &LightcraftApp, p: &egui::Painter, t: &Tokens, canvas: Rect, id: PhotoId) { + match app.renderer.failure_of(Slot::Main, id) { + Some(e) => { + p.text(canvas.center() - vec2(0.0, 10.0), Align2::CENTER_CENTER, crate::i18n::tr("This photo can't be opened"), t.semibold(14.0), t.text); + p.text(canvas.center() + vec2(0.0, 12.0), Align2::CENTER_CENTER, e, t.font(12.0), t.text_dim); + } + None if app.ui.settings.loupe_messages => { + p.text(canvas.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(13.0), t.text_dim); + } + None => {} + } +} + +/// The render failed while a stand-in is up: what is on screen is all there will be (also while a +/// library change retries it). +fn failed_render<'a>(app: &'a LightcraftApp, id: PhotoId, settled: bool, shown: &str) -> Option<&'a str> { + (settled && !matches!(shown, "render" | "none" | "hover") && (!app.renderer.is_pending(Slot::Main) || app.renderer.retrying(Slot::Main))) + .then(|| app.renderer.failure_of(Slot::Main, id)) + .flatten() +} + +/// The pill over the photo (the After in two views; in the crop tool's wide margin, under it): the +/// failure, or View Options' "Show message when loading or rendering photos". +fn status_pill( + ui: &egui::Ui, + app: &LightcraftApp, + photo: &lightcraft_catalog::Photo, + [canvas, over]: [Rect; 2], + nav: Option, + failed: Option<&str>, +) { + match failed { + Some(e) => failed_pill(ui, app, photo, canvas, over, nav, "loupe:renderFailed", e), + None if app.ui.settings.loupe_messages => { + loupe_pill(ui, canvas, over, nav, "loupe:rendering", crate::i18n::tr("Rendering…"), false); + } + None => {} + } +} + +/// Soft proofing: a paper-white surround and the proof's name, as Lightroom shows it. +fn proof_surround(app: &LightcraftApp, p: &egui::Painter, t: &Tokens, canvas: Rect) { + p.rect_filled(canvas, 0.0, Color32::from_gray(238)); + let label = crate::i18n::tr_format!("Proof Preview · {}", app.ui.proof.space.label()); + p.text(pos2(canvas.right() - 16.0, canvas.top() + 14.0), Align2::RIGHT_CENTER, label, t.font(12.5), Color32::from_gray(60)); +} + +/// Request the window renders, remember which sides have one, and release the slot of a side without. +fn request_windows(app: &mut LightcraftApp, ctx: &WindowCtx, views: Vec) -> Vec<(WindowView, crate::region::RegionView)> { + let mut windows: Vec<(WindowView, crate::region::RegionView)> = Vec::new(); + for v in views { + if let Some(view) = request_window(app, ctx, &v) { + windows.push((v, view)); + } + } + for (slot, before) in [(Slot::Region, false), (Slot::RegionBefore, true)] { + let shown = windows.iter().find(|(v, _)| v.before == before).map(|(_, view)| *view); + if before { + app.region_before_view = shown; + } else { + app.region_view = shown; + } + if shown.is_none() { + app.region_tiles.retain(|(b, _), _| *b != before); + app.renderer.release(slot); + } + } + windows +} + +/// The aspect of the texture on screen for this photo and geometry, if one is. +fn visible_texture_aspect(app: &LightcraftApp, id: PhotoId, d: &DevelopSettings, crop_tool: bool) -> Option { + // A previous crop/rotation can stay visible while its replacement renders. Its pixels + // must not override the current frame geometry, even when it is the same photo. + let current_texture = |t: &&crate::render::Tex| t.photo == id && t.look == Some((crate::render::geometry_look(d), !crop_tool)); + app.renderer + .textures + .get(&Slot::Main) + .filter(current_texture) + .or_else(|| app.renderer.textures.get(&Slot::Preview).filter(current_texture)) + .or_else(|| app.renderer.thumb(id).filter(current_texture)) + .map(|tex| tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32) +} + +/// Whether the GPU draws the view (by the GPU's own rule, `handles`; `ready` first: `available` +/// would wait for a device still being created). +fn gpu_draws_view(app: &LightcraftApp, overlay: lightcraft_pipeline::Overlay) -> bool { + lightcraft_engine::gpu::handles( + lightcraft_pipeline::OutputDepth::U8, + app.ui.soft_proof && lightcraft_pipeline::proof_profile(app.ui.proof.profile).is_some(), + overlay, + ) && lightcraft_engine::gpu::ready() + && lightcraft_engine::gpu::available() +} + +/// The line of a wipe. +fn draw_wipe_line(mode: BeforeAfter, p: &egui::Painter, img_rect: Rect, line: Pos2) { + let stroke = Stroke::new(1.5, Color32::WHITE); + match mode { + BeforeAfter::Split => { + p.line_segment([pos2(line.x, img_rect.top()), pos2(line.x, img_rect.bottom())], stroke); + } + BeforeAfter::SplitTopBottom => { + p.line_segment([pos2(img_rect.left(), line.y), pos2(img_rect.right(), line.y)], stroke); + } + _ => {} + } +} + +/// Whether the render on its way is not what is on screen (a stand-in, the last render after an +/// edit, a magnified frame under its window). +fn render_coming(app: &LightcraftApp, shown: &str, windows: &[(WindowView, crate::region::RegionView)], mode: ViewMode) -> bool { + let whole: &[Slot] = if mode.show_before && !mode.split { &[Slot::Before] - } else if split || split_view { + } else if mode.split || mode.split_view { &[Slot::Main, Slot::Before] } else { &[Slot::Main] }; - let coming = !matches!(shown, "none" | "hover") - && whole.iter().chain(windows.iter().map(|(v, _)| &v.slot)).any(|s| app.renderer.is_pending(*s) && !app.renderer.retrying(*s)); - let keys = - [Slot::Main, Slot::Before, Slot::Region, Slot::RegionBefore].map(|s| app.renderer.textures.get(&s).filter(|t| t.photo == id).map(|t| t.key)); - let rendering = ui.ctx().data_mut(|d| rendering_overdue(d.get_temp_mut_or(egui::Id::unique("loupe-rendering"), None), keys, coming, now)); - // the render failed while a stand-in is up: what is on screen is all there will be (also while - // a library change retries it) - let failed = (!rendering - && (!show_before || split) - && !matches!(shown, "render" | "none" | "hover") - && (!app.renderer.is_pending(Slot::Main) || app.renderer.retrying(Slot::Main))) - .then(|| app.renderer.failure_of(Slot::Main, id)) - .flatten(); - if rendering || failed.is_some() { - // over the photo (the After in two views); in the crop tool's wide margin, under it - let over = if crop_tool { canvas } else { img_rect.intersect(if split { main_area } else { canvas }) }; - let over = if over.is_positive() { over } else { canvas }; - match failed { - Some(e) => failed_pill(ui, app, &photo, canvas, over, nav, "loupe:renderFailed", e), - // View Options' Show message when loading or rendering photos - None if app.ui.settings.loupe_messages => { - loupe_pill(ui, canvas, over, nav, "loupe:rendering", crate::i18n::tr("Rendering…"), false); - } - None => {} + !matches!(shown, "none" | "hover") + && whole.iter().chain(windows.iter().map(|(v, _)| &v.slot)).any(|s| app.renderer.is_pending(*s) && !app.renderer.retrying(*s)) +} + +/// What the active tool needs to know about the loupe. +struct ToolView<'a> { + right: RightPanel, + map: &'a CanvasMap, + frame: &'a Frame, + d: &'a DevelopSettings, + id: PhotoId, + split: bool, + native: [usize; 2], + aspect: f32, +} + +/// Route the pointer to the right panel's tool on the photo, or to the general interactions. +fn tool_interaction(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, tv: &ToolView, v: &Views) { + let (map, d) = (tv.map, tv.d); + match tv.right { + RightPanel::Crop => crop_overlay(app, ui, resp, map, tv.frame, d, tv.id), + RightPanel::Masking => mask_overlay(app, ui, resp, map, d), + RightPanel::Remove => remove_overlay(app, ui, resp, map, d), + RightPanel::RedEye => eye_overlay(app, ui, resp, map, d), + _ => { + // side by side / stacked: Before shows the same frame, so a click in its view (white + // balance, colour samples, targeted adjustment) means the same point of the photo. + // Zoomed in, a view's image reaches past its pane: what counts is the pane clicked. + // A click-zoom there centres both views on the point clicked (#1742). + let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); + let (map, img, pane) = match (tv.split, v.areas.first(), pointer) { + (true, Some(before), Some(q)) if before.contains(q) => (CanvasMap::new(tv.frame, v.before_img), v.before_img, *before), + _ => (CanvasMap::new(tv.frame, v.img_rect), v.img_rect, v.main_area), + }; + general_interaction(app, ui, resp, &map, &PhotoPlacement { img, canvas: pane, native: tv.native, aspect: tv.aspect }) } } - if let Some(why) = photo.preview_only.as_deref().filter(|_| !fullscreen) { - preview_only_pill(ui, canvas, why); +} + +/// The loupe's render plan, without a window for a view the GPU already refused one for. +fn loupe_plan(app: &LightcraftApp, id: PhotoId, look: u64, sizes: crate::region::ViewSizes) -> crate::region::LoupePlan { + let plan = crate::region::plan(&app.ui.settings, sizes); + if plan.window_edge.is_some_and(|edge| app.window_refused == Some((id, look, edge))) { + return crate::region::plan(&app.ui.settings, crate::region::ViewSizes { windows: false, ..sizes }); + } + plan +} + +fn whole_frame_edge(app: &LightcraftApp, interacting: bool, plan: &crate::region::LoupePlan, native_long: usize, texture_side: usize) -> usize { + 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)) + } +} + +/// What a loupe render request is for. +struct LoupeAsk { + id: PhotoId, + with_crop: bool, + interacting: bool, + fit_px: [usize; 2], + overlay: lightcraft_pipeline::Overlay, +} + +/// Request the loupe's own render at `[rw, rh]` (drafts during drags). +fn request_main(app: &mut LightcraftApp, ui: &egui::Ui, a: &LoupeAsk, [rw, rh]: [usize; 2], whole_draft: bool) { + let (id, interacting, fit_px, overlay) = (a.id, a.interacting, a.fit_px, a.overlay); + if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), a.with_crop) { + 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(fit_px[0], fit_px[1]) }; + 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); + // a drag's draft that renders in a few ms (GPU) is drawn in this frame, not the next (zoomed + // in, what changes on screen is the window's draft) + #[cfg(not(target_arch = "wasm32"))] + if interacting && whole_draft { + let frame = ui.input(|i| i.predicted_dt).clamp(1.0 / 240.0, 1.0 / 30.0); + // (a frame time that is not a number waits for nothing) + let budget = std::time::Duration::try_from_secs_f32(frame * 0.4).unwrap_or_default(); + app.renderer.wait_for_draft(ui.ctx(), &mut app.session, budget); + } + } +} + +/// Hovering a preset or profile: the photo with that look, shown instead of the loupe render once +/// it is ready (nothing is committed). The key of its render and the label to show. +fn request_hover(app: &mut LightcraftApp, a: &LoupeAsk, [sw, sh]: [usize; 2]) -> Option<(u64, String)> { + match app.hover_preview.clone() { + Some(h) if !a.interacting => app.session.preview_job(a.id, sw.max(8), sh.max(8), a.with_crop, &h.settings).map(|job| { + // (the same display fit as the After view, drawn texel for texel like it) + let job = job.with_display_fit(a.fit_px[0], a.fit_px[1]); + let key = job.key; + app.renderer.request(Slot::Hover, job, 105); + (key, h.label) + }), + _ => None, + } +} + +/// The Before render, at the stand-in size. +fn request_before(app: &mut LightcraftApp, a: &LoupeAsk, [sw, sh]: [usize; 2]) { + if let Some(job) = app.session.render_job(a.id, sw.max(8), sh.max(8), true, a.with_crop) { + // (the same display fit as the After view, so both panes are the same texture size) + let job = if a.interacting { job } else { job.with_display_fit(a.fit_px[0], a.fit_px[1]) }; + app.renderer.request(Slot::Before, job, 90); + } +} + +/// What a view slot shows: its own render of this photo, else the stand-ins (no blank frame +/// between photos, and the full render replaces them in place). Returns what it drew. +fn draw_slot(app: &LightcraftApp, p: &egui::Painter, id: PhotoId, ppp: f32, slot: Slot, r: Rect, clip: Rect) -> &'static str { + let mine = |s: Slot| app.renderer.textures.get(&s).filter(|t| t.photo == id); + let (tex, what) = if let Some(t) = mine(slot) { + (t, "render") + } else if let Some(t) = mine(Slot::Preview) { + (t, t.quick.map(quick_name).unwrap_or("preview")) + } else if let Some(t) = app.renderer.thumb(id) { + (t, "thumb") + } else { + return "none"; + }; + 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 +} + +/// The hovered look's render over the photo, with its label. +fn draw_hover(p: &egui::Painter, t: &Tokens, tex: &crate::render::Tex, label: &str, [img_rect, canvas]: [Rect; 2], badge_top_inset: f32) { + 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.to_string(), t.font(12.0), Color32::WHITE); + let bg = label_badge_rect(hover_rect, canvas, g.size(), badge_top_inset); + paint_badge(p, bg, g); +} + +/// A label on a dark pill. +fn paint_badge(p: &egui::Painter, bg: Rect, g: std::sync::Arc) { + p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); + p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); +} + +/// No photo is active: the empty-library message if the library is empty, else "No photo selected". +fn show_no_photo(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) { + if super::empty_library::shown(&app.session) { + super::empty_library::show(app, ui, canvas); + } else { + super::empty_message(ui, canvas, "No photo selected", "Choose a photo in the grid or filmstrip"); + } +} + +/// The margin round the photo: none full screen, wide in the crop tool. +fn loupe_margin(fullscreen: bool, crop_tool: bool) -> f32 { + if fullscreen { + 0.0 + } else if crop_tool { + 48.0 + } else { + 24.0 + } +} + +/// The labels under each photo, or under its pane when the photo reaches past it. +fn split_labels(p: &egui::Painter, t: &Tokens, [before, after]: [(Rect, Rect); 2]) { + let label_at = |r: Rect, pane: Rect| pos2(r.left().max(pane.left()), r.bottom().min(pane.bottom()) + 14.0); + p.text(label_at(before.0, before.1), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text_dim); + p.text(label_at(after.0, after.1), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim); +} + +/// What is drawn over the photo. +struct OverPhoto<'a> { + photo: &'a lightcraft_catalog::Photo, + d: &'a DevelopSettings, + map: &'a CanvasMap, + canvas: Rect, + img_rect: Rect, + fullscreen: bool, + crop_tool: bool, + /// the clipping overlay may show (not in the Before view) + clipping: bool, +} + +/// The overlays over the photo: clipping, layout image, info, filter pill, faces, focus point and +/// the transform grid. +fn photo_overlays(app: &mut LightcraftApp, ui: &mut egui::Ui, p: &egui::Painter, o: &OverPhoto) { + let (canvas, img_rect) = (o.canvas, o.img_rect); + if app.ui.show_clipping && o.clipping && !o.fullscreen { + clipping_overlay(app, p, img_rect); + } + // View ▸ Loupe Overlay ▸ Layout Image, over the photo (the After view in a before/after) + super::layout_overlay::draw(app, p, img_rect); + register(ui, "canvas:image", img_rect); + info_overlay(app, p, canvas, o.photo); + if !o.fullscreen { + filter_pill(app, ui, canvas); + } + if app.ui.face_boxes { + face_boxes(app, ui, p, o.map, o.photo, o.d); + } + if app.ui.settings.focus_point && !o.crop_tool { + draw_focus_point(&p.with_clip_rect(img_rect.intersect(canvas)), img_rect, o.photo, "canvas:focusPoint"); + } + transform_grid(app, p, img_rect); +} + +fn request_stand_in(app: &mut LightcraftApp, id: PhotoId, edge: usize, with_crop: bool) { + if app.renderer.textures.get(&Slot::Main).is_none_or(|t| t.photo != id) + && let Some(q) = app.session.quick_view_job(id, edge.max(8), with_crop) + { + app.renderer.request_quick(Slot::Preview, q, 110); } - crate::widgets::context_menu(&resp, |ui| { - crate::widgets::menu_button(ui, crate::i18n::tr("Zoom"), |ui| { - for (label, cmd) in [("Fit", "view.zoomFit"), ("100%", "view.zoom100"), ("Zoom In", "view.zoomIn"), ("Zoom Out", "view.zoomOut")] { - if ui.button(crate::i18n::tr(label)).clicked() { - let _ = app.run(cmd, json!({})); - } - } - }); - ui.separator(); - super::grid::context_menu(app, ui, id); - }); } /// Neighbour prefetch: below on-screen thumbnails, above background thumbnail refreshes. diff --git a/crates/ui-egui/src/panels/detail/masks.rs b/crates/ui-egui/src/panels/detail/masks.rs index c150eac53..365ae2311 100644 --- a/crates/ui-egui/src/panels/detail/masks.rs +++ b/crates/ui-egui/src/panels/detail/masks.rs @@ -291,7 +291,6 @@ pub(super) fn color_range_pick(app: &mut LightcraftApp, ui: &egui::Ui, resp: &eg /// component (click selects its mask, drag moves the component), and brush painting. The mask /// itself shows as a rendered overlay ([`view_overlay`]). pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { - let long = (map.rect.width().max(map.rect.height())) as f64; let active = app.session.active_mask; let clip = app.canvas_rect.unwrap_or(map.rect); let p = &ui.painter_at(clip); @@ -310,60 +309,9 @@ pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &eg // mask so nothing else becomes draggable. The component check keeps this in step with the // painted region (`view_overlay` at detail.rs:1354 only covers masks with components). let show_outline = sel || (app.ui.mask_overlay_all && m.visible && !m.components.is_empty()); - let stroke_a: u8 = if sel { 230 } else { 170 }; for (ci, c) in m.components.iter().enumerate() { - match &c.shape { - MaskShape::Radial { center, rx, ry, angle, .. } if show_outline => { - let pts: Vec = (0..64) - .map(|i| { - let a = i as f64 / 64.0 * std::f64::consts::TAU; - let (s, co) = angle.to_radians().sin_cos(); - let (x, y) = (rx * a.cos(), ry * a.sin()); - let l = frame_long_norm(map); - map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)) - }) - .collect(); - p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(stroke_a)))); - - if sel { - let l = frame_long_norm(map); - for (i, a_deg) in [0.0_f64, 90.0_f64, 180.0_f64, 270.0_f64].into_iter().enumerate() { - let a_local = a_deg.to_radians(); - let (s, co) = angle.to_radians().sin_cos(); - let (x, y) = (rx * a_local.cos(), ry * a_local.sin()); - let q = map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)); - p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE)); - register(ui, format!("maskHandle:{}:{ci}:{}", m.id, i + 1), Rect::from_center_size(q, vec2(14.0, 14.0))); - grips.push((m.id, ci, i as u8 + 1, q)); - } - - let (s, co) = angle.to_radians().sin_cos(); - let rot_r = rx + 0.08; - let rot_q = map.screen(Point::new(center.x + (rot_r * co) * l.0, center.y + (rot_r * s) * l.1)); - let h1_q = map.screen(Point::new(center.x + (rx * co) * l.0, center.y + (rx * s) * l.1)); - p.line_segment([h1_q, rot_q], Stroke::new(1.0, Color32::from_white_alpha(200))); - p.circle_stroke(rot_q, 4.0, Stroke::new(1.5, Color32::WHITE)); - register(ui, format!("maskHandle:{}:{ci}:5", m.id), Rect::from_center_size(rot_q, vec2(14.0, 14.0))); - grips.push((m.id, ci, 5, rot_q)); - } - } - MaskShape::Linear { start, end } if show_outline => { - let (a, b) = (map.screen(*start), map.screen(*end)); - let dir = (b - a).normalized(); - let perp = vec2(-dir.y, dir.x) * 3000.0; - let (a1, a2): (u8, u8) = if sel { (230, 110) } else { (170, 80) }; - for (q, alpha) in [(a, a1), (b, a2)] { - p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(alpha))); - } - if sel { - for (i, q) in [a, b].iter().enumerate() { - p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE)); - register(ui, format!("maskHandle:{}:{ci}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0))); - grips.push((m.id, ci, i as u8 + 1, *q)); - } - } - } - _ => {} + if show_outline { + component_outline(ui, p, map, (m.id, ci), &c.shape, sel, &mut grips); } if app.ui.mask_pins && mode.shows(sel, live) @@ -377,8 +325,98 @@ pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &eg } } mask_keys(app, ui, map, d, &grips); - // Object selections (SAM 3) of the selected mask: each click, green to include, red to exclude - // (clicks still on their way to the model included) + object_marks(app, p, map, d, active); + // Colour Range "Pick": the Masking panel owns the loupe's clicks (general_interaction + // never runs while it is open), so the eyedropper is handled here too. + if app.ui.tool == "colorRange" { + if resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + color_range_pick(app, ui, resp, map); + return; + } + if app.ui.tool == "object" { + object_tool(app, ui, resp, map); + return; + } + if app.ui.tool == "brush" { + brush_tool(app, ui, resp, map, p, clip); + return; + } + grip_gestures(app, ui, resp, map, d, &grips); +} + +/// The outline of one mask component, and (on the selected mask) its grips, registered and +/// collected into `grips`. +fn component_outline( + ui: &egui::Ui, + p: &egui::Painter, + map: &CanvasMap, + at: (u32, usize), + shape: &MaskShape, + sel: bool, + grips: &mut Vec<(u32, usize, u8, Pos2)>, +) { + let (mask, ci) = at; + let stroke_a: u8 = if sel { 230 } else { 170 }; + match shape { + MaskShape::Radial { center, rx, ry, angle, .. } => { + let pts: Vec = (0..64) + .map(|i| { + let a = i as f64 / 64.0 * std::f64::consts::TAU; + let (s, co) = angle.to_radians().sin_cos(); + let (x, y) = (rx * a.cos(), ry * a.sin()); + let l = frame_long_norm(map); + map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)) + }) + .collect(); + p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(stroke_a)))); + + if sel { + let l = frame_long_norm(map); + for (i, a_deg) in [0.0_f64, 90.0_f64, 180.0_f64, 270.0_f64].into_iter().enumerate() { + let a_local = a_deg.to_radians(); + let (s, co) = angle.to_radians().sin_cos(); + let (x, y) = (rx * a_local.cos(), ry * a_local.sin()); + let q = map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)); + p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE)); + register(ui, format!("maskHandle:{}:{ci}:{}", mask, i + 1), Rect::from_center_size(q, vec2(14.0, 14.0))); + grips.push((mask, ci, i as u8 + 1, q)); + } + + let (s, co) = angle.to_radians().sin_cos(); + let rot_r = rx + 0.08; + let rot_q = map.screen(Point::new(center.x + (rot_r * co) * l.0, center.y + (rot_r * s) * l.1)); + let h1_q = map.screen(Point::new(center.x + (rx * co) * l.0, center.y + (rx * s) * l.1)); + p.line_segment([h1_q, rot_q], Stroke::new(1.0, Color32::from_white_alpha(200))); + p.circle_stroke(rot_q, 4.0, Stroke::new(1.5, Color32::WHITE)); + register(ui, format!("maskHandle:{}:{ci}:5", mask), Rect::from_center_size(rot_q, vec2(14.0, 14.0))); + grips.push((mask, ci, 5, rot_q)); + } + } + MaskShape::Linear { start, end } => { + let (a, b) = (map.screen(*start), map.screen(*end)); + let dir = (b - a).normalized(); + let perp = vec2(-dir.y, dir.x) * 3000.0; + let (a1, a2): (u8, u8) = if sel { (230, 110) } else { (170, 80) }; + for (q, alpha) in [(a, a1), (b, a2)] { + p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(alpha))); + } + if sel { + for (i, q) in [a, b].iter().enumerate() { + p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE)); + register(ui, format!("maskHandle:{}:{ci}:{i}", mask), Rect::from_center_size(*q, vec2(14.0, 14.0))); + grips.push((mask, ci, i as u8 + 1, *q)); + } + } + } + _ => {} + } +} + +/// Object selections (SAM 3) of the selected mask: each click, green to include, red to exclude +/// (clicks still on their way to the model included). +fn object_marks(app: &LightcraftApp, p: &egui::Painter, map: &CanvasMap, d: &DevelopSettings, active: Option) { let pending = app.session.segmenter.pending_clicks().cloned(); for m in d.masks.iter().filter(|m| Some(m.id) == active) { for (k, c) in m.components.iter().enumerate() { @@ -397,100 +435,104 @@ pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &eg } } } - // Colour Range "Pick": the Masking panel owns the loupe's clicks (general_interaction - // never runs while it is open), so the eyedropper is handled here too. - if app.ui.tool == "colorRange" { - if resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - color_range_pick(app, ui, resp, map); - return; +} + +/// Object tool: a click includes what's under it, ⌥-click leaves it out. +fn object_tool(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap) { + if resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); } - // Object tool: a click includes what's under it, ⌥-click leaves it out - if app.ui.tool == "object" { - if resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - if resp.clicked() - && let Some(q) = resp.interact_pointer_pos() - { - let n = map.norm(q); - let exclude = ui.input(|i| i.modifiers.alt); - match app.run_typed("mask.objectPoint", json!({"x": n.x, "y": n.y, "exclude": exclude})) { - Ok(_) => { - if let Some(mid) = app.session.active_mask { - app.ui.detail_due = Some((app.tick_now() + 1.0, mid)); - } - } - Err(e) => app.ai_error(ui.ctx(), e, Some(("object", "new"))), - } - } - return; - } - // brush tool - if app.ui.tool == "brush" { - let r = (app.ui.brush_size as f64 * long) as f32; - if let Some(h) = resp.hover_pos() { - ui.ctx().set_cursor_icon(egui::CursorIcon::None); - p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE)); - p.circle_stroke(h, r * (1.0 - app.ui.brush_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(120))); - } - if let Some(q) = resp.interact_pointer_pos() - && (resp.drag_started() || resp.dragged() || resp.clicked()) - { - let n = map.norm(q); - let force = if app.ui.brush_pressure { ui.input(pen_force) } else { None }; - match &mut app.gesture { - // a held, unmoving pointer records nothing: coverage builds by passes, not time (#518) - Some(Gesture::Brush { points, .. }) if points.last() == Some(&n) => {} - Some(Gesture::Brush { points, pressure }) => { - record_pressure(pressure, points.len(), force); - points.push(n); - } - _ => { - let mut pressure = Vec::new(); - record_pressure(&mut pressure, 0, force); - app.gesture = Some(Gesture::Brush { points: vec![n], pressure }); + if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + { + let n = map.norm(q); + let exclude = ui.input(|i| i.modifiers.alt); + match app.run_typed("mask.objectPoint", json!({"x": n.x, "y": n.y, "exclude": exclude})) { + Ok(_) => { + if let Some(mid) = app.session.active_mask { + app.ui.detail_due = Some((app.tick_now() + 1.0, mid)); } } + Err(e) => app.ai_error(ui.ctx(), e, Some(("object", "new"))), } - // the stroke being painted (the render shows it once it's committed), with the committed - // stroke's dab spacing, feather and flow (issue #517) - if let Some(Gesture::Brush { points, pressure }) = &app.gesture { - let screen: Vec = points.iter().map(|q| map.screen(*q)).collect(); - let opacity = if app.ui.mask_overlay && app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 }; - let params = crate::brush_live::LiveParams { - clip, - scale: ui.ctx().pixels_per_point(), - r, - hard: r * (1.0 - (app.ui.brush_feather / 100.0).clamp(0.0, 1.0)), - flow: (app.ui.brush_flow / 100.0).clamp(0.0, 1.0), - density: 1.0, - color: app.ui.mask_overlay_color, - opacity: opacity.clamp(0.0, 1.0), - }; - crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen, pressure); - } else { - app.brush_live = None; - } - if (resp.drag_stopped() || resp.clicked()) - && let Some(Gesture::Brush { points, pressure }) = app.gesture.take() - { - app.brush_live = None; - let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); - // holding ⌥ (Alt) paints with the other mode: Erase while adding, Add while erasing - let (alt, shift) = resp.ctx.input(|i| (i.modifiers.alt, i.modifiers.shift)); - let erase = app.ui.brush_erase != alt; - // a ⇧-click draws a straight line from where the last stroke ended, as in Lightroom - let connect = shift && resp.clicked() && pts.len() == 1; - let _ = app.run( - "mask.brushStroke", - json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, - "erase": erase, "autoMask": app.ui.brush_auto_mask, "pressure": pressure, "connect": connect}), - ); - } - return; } +} + +/// Brush tool: the cursor, the stroke being painted and its commit. +fn brush_tool(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, p: &egui::Painter, clip: Rect) { + let long = (map.rect.width().max(map.rect.height())) as f64; + let r = (app.ui.brush_size as f64 * long) as f32; + if let Some(h) = resp.hover_pos() { + ui.ctx().set_cursor_icon(egui::CursorIcon::None); + p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE)); + p.circle_stroke(h, r * (1.0 - app.ui.brush_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(120))); + } + if let Some(q) = resp.interact_pointer_pos() + && (resp.drag_started() || resp.dragged() || resp.clicked()) + { + let n = map.norm(q); + let force = if app.ui.brush_pressure { ui.input(pen_force) } else { None }; + match &mut app.gesture { + // a held, unmoving pointer records nothing: coverage builds by passes, not time (#518) + Some(Gesture::Brush { points, .. }) if points.last() == Some(&n) => {} + Some(Gesture::Brush { points, pressure }) => { + record_pressure(pressure, points.len(), force); + points.push(n); + } + _ => { + let mut pressure = Vec::new(); + record_pressure(&mut pressure, 0, force); + app.gesture = Some(Gesture::Brush { points: vec![n], pressure }); + } + } + } + // the stroke being painted (the render shows it once it's committed), with the committed + // stroke's dab spacing, feather and flow (issue #517) + if let Some(Gesture::Brush { points, pressure }) = &app.gesture { + let screen: Vec = points.iter().map(|q| map.screen(*q)).collect(); + let opacity = if app.ui.mask_overlay && app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 }; + let params = crate::brush_live::LiveParams { + clip, + scale: ui.ctx().pixels_per_point(), + r, + hard: r * (1.0 - (app.ui.brush_feather / 100.0).clamp(0.0, 1.0)), + flow: (app.ui.brush_flow / 100.0).clamp(0.0, 1.0), + density: 1.0, + color: app.ui.mask_overlay_color, + opacity: opacity.clamp(0.0, 1.0), + }; + crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen, pressure); + } else { + app.brush_live = None; + } + if (resp.drag_stopped() || resp.clicked()) + && let Some(Gesture::Brush { points, pressure }) = app.gesture.take() + { + app.brush_live = None; + let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); + // holding ⌥ (Alt) paints with the other mode: Erase while adding, Add while erasing + let (alt, shift) = resp.ctx.input(|i| (i.modifiers.alt, i.modifiers.shift)); + let erase = app.ui.brush_erase != alt; + // a ⇧-click draws a straight line from where the last stroke ended, as in Lightroom + let connect = shift && resp.clicked() && pts.len() == 1; + let _ = app.run( + "mask.brushStroke", + json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, + "erase": erase, "autoMask": app.ui.brush_auto_mask, "pressure": pressure, "connect": connect}), + ); + } +} + +/// Grabbing mask grips and component bodies: select, drag, release. +fn grip_gestures( + app: &mut LightcraftApp, + ui: &egui::Ui, + resp: &egui::Response, + map: &CanvasMap, + d: &DevelopSettings, + grips: &[(u32, usize, u8, Pos2)], +) { + let active = app.session.active_mask; // the grip under the pointer: the selected mask's first, then the nearest let hit = |q: Pos2| { grips From ce3ce07771c0c46436a93acd02fe0f8be4beae71 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 03:20:48 +0000 Subject: [PATCH 36/39] refactor(ui-egui): split the top bar and slider widget --- crates/ui-egui/src/panels/topbar.rs | 382 ++++++++++--------- crates/ui-egui/src/widgets.rs | 557 ++++++++++++++++------------ 2 files changed, 529 insertions(+), 410 deletions(-) diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 2ba807dcc..5e3fea8a1 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -51,185 +51,202 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if app.integrated_titlebar { window_handle(ui, bar); } - ui.horizontal_centered(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; - if icon_button(ui, "sidebar", Icon::Sidebar, vec2(30.0, 30.0), app.ui.left_panel, true, crate::i18n::tr("Show/hide My Photos panel")) - .clicked() - { - let _ = app.run("view.leftPanel", json!({})); - } - ui.add_space(8.0); - if icon_button(ui, "back", Icon::Back, vec2(30.0, 30.0), false, true, crate::i18n::tr("Back")).clicked() { - let _ = app.run("view.back", json!({})); - } - icon_button(ui, "forward", Icon::Forward, vec2(30.0, 30.0), false, false, crate::i18n::tr("Forward")); - if !app.native_menu { - // no native menu bar (web, Windows, Linux): menus in the top bar - ui.add_space(10.0); - let room = search_left - ui.cursor().left() - 16.0; - crate::menubar::show_in_window(app, ui, room); - } - }); - // search field (centred on the window) - let id = crate::text_field::id("field:search"); - if std::mem::take(&mut app.ui.focus_search) { - ui.memory_mut(|m| m.request_focus(id)); + nav_buttons(app, ui, search_left); + search_field(app, ui, &t, sr); + filter_button(app, ui, &t, sr); + let x = right_icons(app, ui, &t, full); + appearance_button(app, ui, &t, full, x); + }); +} + +/// Sidebar toggle, back/forward and, without a native menu bar, the in-window menus. +fn nav_buttons(app: &mut LightcraftApp, ui: &mut egui::Ui, search_left: f32) { + ui.horizontal_centered(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if icon_button(ui, "sidebar", Icon::Sidebar, vec2(30.0, 30.0), app.ui.left_panel, true, crate::i18n::tr("Show/hide My Photos panel")) + .clicked() + { + let _ = app.run("view.leftPanel", json!({})); + } + ui.add_space(8.0); + if icon_button(ui, "back", Icon::Back, vec2(30.0, 30.0), false, true, crate::i18n::tr("Back")).clicked() { + let _ = app.run("view.back", json!({})); + } + icon_button(ui, "forward", Icon::Forward, vec2(30.0, 30.0), false, false, crate::i18n::tr("Forward")); + if !app.native_menu { + // no native menu bar (web, Windows, Linux): menus in the top bar + ui.add_space(10.0); + let room = search_left - ui.cursor().left() - 16.0; + crate::menubar::show_in_window(app, ui, room); + } + }); +} + +/// The centred search field with its placeholder and focus ring. +fn search_field(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, sr: Rect) { + // search field (centred on the window) + let id = crate::text_field::id("field:search"); + if std::mem::take(&mut app.ui.focus_search) { + ui.memory_mut(|m| m.request_focus(id)); + } + let focused = ui.memory(|m| m.has_focus(id)); + ui.painter().rect( + sr, + 4.0, + if focused { t.canvas } else { t.field }, + Stroke::new(1.0, if focused { t.accent } else { t.field_border }), + StrokeKind::Inside, + ); + let mut child = + ui.new_child(egui::UiBuilder::new().max_rect(sr.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + let empty = app.ui.search.is_empty(); + if empty && !focused { + let g = child.painter().layout_no_wrap(crate::i18n::tr("Search Photos").into(), t.font(13.5), t.text_dim); + let w = g.size().x + 24.0; + let x0 = sr.center().x - w / 2.0; + paint(child.painter(), Rect::from_min_size(pos2(x0, sr.center().y - 8.0), vec2(16.0, 16.0)), Icon::Search, t.text_dim); + child.painter().galley(pos2(x0 + 24.0, sr.center().y - g.size().y / 2.0), g, t.text_dim); + } + let resp = crate::text_field::TextField::singleline("field:search", &mut app.ui.search) + .label(crate::i18n::tr("Search Photos")) + .frame(false) + .width(sr.width() - 20.0) + .font(t.font(13.5)) + .text_color(t.text) + .show(&mut child) + .response; + if resp.changed() { + let q = app.ui.search.clone(); + let _ = app.run("library.filter", json!({"text": q})); + } +} + +/// The filter icon beside the search field, with the active-filter badge. +fn filter_button(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, sr: Rect) { + // filter icon right of the search field + let fr = Rect::from_center_size(pos2(sr.right() + 22.0, sr.center().y), vec2(28.0, 28.0)); + let fresp = ui.interact(fr, egui::Id::unique("filter-btn"), Sense::click()); + crate::widgets::announce(&fresp, crate::i18n::tr("Filters")); + register(ui, "icon:filter", fr); + let filtering = app.session.filter != Default::default() || app.ui.filter_bar; + paint( + ui.painter(), + fr.shrink(6.0), + Icon::Filter, + if filtering { + t.accent + } else if fresp.hovered() { + t.text + } else { + t.icon + }, + ); + // badge: how many filters are on, even with the filter bar closed + let active = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog).len(); + if active > 0 { + let c = fr.right_top() + vec2(-3.0, 8.0); + ui.painter().circle_filled(c, 7.0, t.accent); + ui.painter().text(c, Align2::CENTER_CENTER, active.to_string(), t.semibold(9.5), t.canvas); + } + let fresp = fresp.on_hover_text(if active > 0 { + crate::i18n::tr_format!("Filter bar — {active} active filter{}", if active == 1 { "" } else { "s" }, active = active) + } else { + crate::i18n::tr("Filter bar").into() + }); + if fresp.clicked() { + let _ = app.run("view.filterBar", json!({})); + } +} + +/// The right-side action icons; returns the x where the next icon would go. +fn right_icons(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, full: Rect) -> f32 { + // right icons + let mut x = full.right() - 18.0; + // saving is failing: the cloud icon turns into a warning until a save succeeds + let unsaved = app.session.unsaved().map(|(n, e)| unsaved_tip(n, e)); + let cloud_tip = unsaved.as_deref().unwrap_or("Local library — no cloud account needed"); + // Import Photos… beside Export, always one click away, with the key it is bound to now + let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; + let import_tip = match crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "file.addPhotos") { + Some(sc) => format!("{} ({})", crate::i18n::tr("Import Photos…"), crate::menubar::shortcut_text(sc, mac)), + None => crate::i18n::tr("Import Photos…").to_string(), + }; + for (id, icon, tip, cmd) in [ + ("discord", Icon::Chat, "Join the ArtCraft community on Discord", "app.discord"), + ("cloud", Icon::Cloud, cloud_tip, ""), + ("help", Icon::Help, "Keyboard shortcuts", "app.shortcuts"), + ("share", Icon::Share, "Export", "dialog.export"), + ("import", Icon::Plus, import_tip.as_str(), "file.addPhotos"), + ("bell", Icon::Bell, "Activity", "panel.activity"), + ] { + let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); + let resp = ui.interact(r, egui::Id::unique(("top", id)), Sense::click()).on_hover_text(crate::i18n::tr(tip)); + crate::widgets::announce(&resp, crate::i18n::tr(tip)); + register(ui, format!("icon:{id}"), r); + let warn = id == "cloud" && unsaved.is_some(); + let colour = if warn { + t.caution + } else if resp.hovered() { + t.text + } else { + t.icon + }; + paint(ui.painter(), r.shrink(5.0), icon, colour); + if warn { + let c = r.right_top() + vec2(-5.0, 6.0); + ui.painter().circle_filled(c, 6.0, t.reject); + ui.painter().text(c, Align2::CENTER_CENTER, "!", t.semibold(9.5), t.canvas); + register(ui, "indicator:unsaved", r); + } + // a refusal says why (Import while a Lightroom catalog is coming in) + if resp.clicked() + && !cmd.is_empty() + && let Err(e) = app.run(cmd, json!({})) + { + app.toast_error(ui.ctx(), e); + } + x -= 40.0; + } + x +} + +/// The appearance icon at `x`, with its theme-favourites menu. +fn appearance_button(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, full: Rect, x: f32) { + // Appearance Mode: a click steps Auto → Light → Dark → Auto; the icon shows the mode + let (icon, mode) = appearance_icon(app.ui.settings.appearance_mode); + let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); + let tip = format!("{}: {}", crate::i18n::tr("Appearance Mode"), crate::i18n::tr(mode)); + let resp = ui.interact(r, egui::Id::unique(("top", "appearance")), Sense::click()).on_hover_text(tip.clone()); + crate::widgets::announce(&resp, tip.clone()); + register(ui, "icon:appearance", r); + paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon }); + if resp.clicked() { + let _ = app.run("view.appearance", json!({})); + } + crate::widgets::context_menu(&resp, |ui| { + let settings = ui.button(crate::i18n::tr("Appearance Settings…")); + register(ui, "menu:appearanceSettings", settings.rect); + if settings.clicked() { + let _ = app.run("app.settings", json!({"tab": "interface"})); + ui.close(); + } + let favorites = app.ui.settings.favorite_themes.clone(); + for dark in [false, true] { + let group: Vec<_> = + crate::theme::ThemeKind::ALL.iter().filter(|k| k.is_dark() == dark && favorites.iter().any(|id| id == k.id())).collect(); + if !group.is_empty() { + ui.separator(); } - let focused = ui.memory(|m| m.has_focus(id)); - ui.painter().rect( - sr, - 4.0, - if focused { t.canvas } else { t.field }, - Stroke::new(1.0, if focused { t.accent } else { t.field_border }), - StrokeKind::Inside, - ); - let mut child = - ui.new_child(egui::UiBuilder::new().max_rect(sr.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); - let empty = app.ui.search.is_empty(); - if empty && !focused { - let g = child.painter().layout_no_wrap(crate::i18n::tr("Search Photos").into(), t.font(13.5), t.text_dim); - let w = g.size().x + 24.0; - let x0 = sr.center().x - w / 2.0; - paint(child.painter(), Rect::from_min_size(pos2(x0, sr.center().y - 8.0), vec2(16.0, 16.0)), Icon::Search, t.text_dim); - child.painter().galley(pos2(x0 + 24.0, sr.center().y - g.size().y / 2.0), g, t.text_dim); - } - let resp = crate::text_field::TextField::singleline("field:search", &mut app.ui.search) - .label(crate::i18n::tr("Search Photos")) - .frame(false) - .width(sr.width() - 20.0) - .font(t.font(13.5)) - .text_color(t.text) - .show(&mut child) - .response; - if resp.changed() { - let q = app.ui.search.clone(); - let _ = app.run("library.filter", json!({"text": q})); - } - // filter icon right of the search field - let fr = Rect::from_center_size(pos2(sr.right() + 22.0, sr.center().y), vec2(28.0, 28.0)); - let fresp = ui.interact(fr, egui::Id::unique("filter-btn"), Sense::click()); - crate::widgets::announce(&fresp, crate::i18n::tr("Filters")); - register(ui, "icon:filter", fr); - let filtering = app.session.filter != Default::default() || app.ui.filter_bar; - paint( - ui.painter(), - fr.shrink(6.0), - Icon::Filter, - if filtering { - t.accent - } else if fresp.hovered() { - t.text - } else { - t.icon - }, - ); - // badge: how many filters are on, even with the filter bar closed - let active = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog).len(); - if active > 0 { - let c = fr.right_top() + vec2(-3.0, 8.0); - ui.painter().circle_filled(c, 7.0, t.accent); - ui.painter().text(c, Align2::CENTER_CENTER, active.to_string(), t.semibold(9.5), t.canvas); - } - let fresp = fresp.on_hover_text(if active > 0 { - crate::i18n::tr_format!("Filter bar — {active} active filter{}", if active == 1 { "" } else { "s" }, active = active) - } else { - crate::i18n::tr("Filter bar").into() - }); - if fresp.clicked() { - let _ = app.run("view.filterBar", json!({})); - } - // right icons - let mut x = full.right() - 18.0; - // saving is failing: the cloud icon turns into a warning until a save succeeds - let unsaved = app.session.unsaved().map(|(n, e)| { - crate::i18n::tr_format!( - "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.", - if n == 1 { "" } else { "s" }, - if n == 1 { "it" } else { "them" }, - e = e, - n = n - ) - }); - let cloud_tip = unsaved.as_deref().unwrap_or("Local library — no cloud account needed"); - // Import Photos… beside Export, always one click away, with the key it is bound to now - let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; - let import_tip = match crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "file.addPhotos") { - Some(sc) => format!("{} ({})", crate::i18n::tr("Import Photos…"), crate::menubar::shortcut_text(sc, mac)), - None => crate::i18n::tr("Import Photos…").to_string(), - }; - for (id, icon, tip, cmd) in [ - ("discord", Icon::Chat, "Join the ArtCraft community on Discord", "app.discord"), - ("cloud", Icon::Cloud, cloud_tip, ""), - ("help", Icon::Help, "Keyboard shortcuts", "app.shortcuts"), - ("share", Icon::Share, "Export", "dialog.export"), - ("import", Icon::Plus, import_tip.as_str(), "file.addPhotos"), - ("bell", Icon::Bell, "Activity", "panel.activity"), - ] { - let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); - let resp = ui.interact(r, egui::Id::unique(("top", id)), Sense::click()).on_hover_text(crate::i18n::tr(tip)); - crate::widgets::announce(&resp, crate::i18n::tr(tip)); - register(ui, format!("icon:{id}"), r); - let warn = id == "cloud" && unsaved.is_some(); - let colour = if warn { - t.caution - } else if resp.hovered() { - t.text - } else { - t.icon - }; - paint(ui.painter(), r.shrink(5.0), icon, colour); - if warn { - let c = r.right_top() + vec2(-5.0, 6.0); - ui.painter().circle_filled(c, 6.0, t.reject); - ui.painter().text(c, Align2::CENTER_CENTER, "!", t.semibold(9.5), t.canvas); - register(ui, "indicator:unsaved", r); - } - // a refusal says why (Import while a Lightroom catalog is coming in) - if resp.clicked() - && !cmd.is_empty() - && let Err(e) = app.run(cmd, json!({})) - { - app.toast_error(ui.ctx(), e); - } - x -= 40.0; - } - // Appearance Mode: a click steps Auto → Light → Dark → Auto; the icon shows the mode - let (icon, mode) = appearance_icon(app.ui.settings.appearance_mode); - let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); - let tip = format!("{}: {}", crate::i18n::tr("Appearance Mode"), crate::i18n::tr(mode)); - let resp = ui.interact(r, egui::Id::unique(("top", "appearance")), Sense::click()).on_hover_text(tip.clone()); - crate::widgets::announce(&resp, tip.clone()); - register(ui, "icon:appearance", r); - paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon }); - if resp.clicked() { - let _ = app.run("view.appearance", json!({})); - } - crate::widgets::context_menu(&resp, |ui| { - let settings = ui.button(crate::i18n::tr("Appearance Settings…")); - register(ui, "menu:appearanceSettings", settings.rect); - if settings.clicked() { - let _ = app.run("app.settings", json!({"tab": "interface"})); + for kind in group { + let r = ui.selectable_label(app.ui.settings.theme(app.system_theme(ui.ctx())) == *kind, crate::i18n::tr(kind.label())); + register(ui, format!("menu:themeFavorite-{}", kind.id()), r.rect); + if r.clicked() { + let _ = app.run("view.theme", json!({"theme": kind.id()})); ui.close(); } - let favorites = app.ui.settings.favorite_themes.clone(); - for dark in [false, true] { - let group: Vec<_> = - crate::theme::ThemeKind::ALL.iter().filter(|k| k.is_dark() == dark && favorites.iter().any(|id| id == k.id())).collect(); - if !group.is_empty() { - ui.separator(); - } - for kind in group { - let r = ui.selectable_label(app.ui.settings.theme(app.system_theme(ui.ctx())) == *kind, crate::i18n::tr(kind.label())); - register(ui, format!("menu:themeFavorite-{}", kind.id()), r.rect); - if r.clicked() { - let _ = app.run("view.theme", json!({"theme": kind.id()})); - ui.close(); - } - } - } - }); - let _ = Align2::CENTER_CENTER; - }); + } + } + }); } /// The appearance button's icon and the mode's name for its tooltip. @@ -241,3 +258,16 @@ pub fn appearance_icon(mode: crate::state::AppearanceMode) -> (Icon, &'static st AppearanceMode::Dark => (Icon::Moon, "Dark Mode"), } } + +/// Tooltip for the warning cloud icon: `n` changes held in memory because saving failed with `e`. +fn unsaved_tip(n: usize, e: &str) -> String { + let s = crate::i18n::plural_s(n); + let it = if n == 1 { "it" } else { "them" }; + crate::i18n::tr_format!( + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.", + s, + it, + e = e, + n = n + ) +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 513208066..89185d991 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -398,25 +398,15 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ let pad_r = 22.0; let label_rect = Rect::from_min_size(pos2(row.left() + pad_l, row.top() + 4.0), vec2(w - pad_l - pad_r, 18.0)); let track_rect = Rect::from_min_max(pos2(row.left() + pad_l, row.top() + 22.0), pos2(row.right() - pad_r, row.top() + 40.0)); + let value_rect = Rect::from_min_max(pos2(label_rect.right() - 64.0, label_rect.top()), label_rect.max); let id = ui.scope_id().with(spec.id); + let g = SliderLayout { spec, value, enabled, id, track_rect, value_rect }; let resp = ui.interact(track_rect.expand2(vec2(8.0, 2.0)), id, if enabled { Sense::click_and_drag() } else { Sense::hover() }); - let wheel_id = id.with("wheel-rem"); let (wheel_armed, pointer_in_row) = update_wheel_arming(ui, id, &resp, row, enabled); // screen readers: a slider named after its control, with its value let label_text = crate::i18n::tr(label_override.unwrap_or(spec.label)).to_string(); resp.widget_info(|| egui::WidgetInfo::slider(enabled, value, label_text.clone())); - // and its range, step and the value as shown ("+0.50", "5600") - let shown = shown_value(spec, value); - ui.ctx().accesskit_node_builder(resp.id, |node| { - node.set_min_numeric_value(spec.min); - node.set_max_numeric_value(spec.max); - node.set_numeric_value_step(spec.step); - node.set_value(shown); - if enabled { - node.add_action(egui::accesskit::Action::Increment); - node.add_action(egui::accesskit::Action::Decrement); - } - }); + describe_slider(ui, &g, &resp); // the name: double-click it to reset, as the thumb (a disabled slider's name is only text) let label_resp = ui.interact(label_rect, id.with("label"), if enabled { Sense::click() } else { Sense::hover() }); // say how to reset (issue #1436: the double-click was hard to discover), as the curve channels do @@ -427,246 +417,41 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ register(ui, format!("slider:{}", spec.id), track_rect); // the value: click it to type an exact one, drag it sideways to scrub (over the label, so it // takes the click) - let value_rect = Rect::from_min_max(pos2(label_rect.right() - 64.0, label_rect.top()), label_rect.max); let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click_and_drag() } else { Sense::hover() }); crate::widgets::announce(&value_resp, format!("{label_text}: {}", crate::i18n::tr("Click to type a value, drag to change it"))); register(ui, format!("sliderValue:{}", spec.id), value_rect); // say that an exact value can be typed here (issue #534: an exact crop angle was hard to find) let value_resp = if enabled { value_resp.on_hover_text(crate::i18n::tr("Click to type a value, drag to change it")) } else { value_resp }; - let typing_id = id.with("typing"); // Keep the short focus-acquisition guard, but let an explicit Return/Escape end the edit. - let mut typing: Option<(String, u8, bool)> = ui.data(|m| m.get_temp(typing_id)); + let mut typing: Option = ui.data(|m| m.get_temp(g.typing_id())); // A scrub: the unrounded track position (0..1) it has reached, so a slow drag adds up instead // of rounding back to the same step every frame. - let scrub_id = id.with("scrub-pos"); - let mut scrub: Option = if value_resp.dragged() || value_resp.drag_stopped() { ui.data(|m| m.get_temp(scrub_id)) } else { None }; - let mut scrub_started = false; - if enabled - && scrub.is_none() - && value_resp.dragged() - && let (Some(origin), Some(p)) = ui.input(|i| (i.pointer.press_origin(), i.pointer.interact_pos())) - && (p.x - origin.x).abs() >= SCRUB_START - { - // a press on the value that moves sideways scrubs: the edit the press opened is dropped - typing = None; - ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id)); - ui.memory_mut(|m| m.surrender_focus(crate::text_field::id(&format!("sliderField:{}", spec.id)))); - // the travel so far counts, so the value doesn't lag the pointer by the start distance - let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 }; - scrub = Some((spec.position(value) + f64::from((p.x - origin.x) / track_rect.width()) * fine).clamp(0.0, 1.0)); - scrub_started = true; - } + let mut scrub: Option = if value_resp.dragged() || value_resp.drag_stopped() { ui.data(|m| m.get_temp(g.scrub_id())) } else { None }; + let scrub_started = begin_scrub(ui, &g, &value_resp, &mut scrub, &mut typing); if scrub.is_none() { - ui.data_mut(|m| m.remove::(scrub_id)); - } - let value_pressed = value_resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)); - // Focus on the owned primary press before fast typing; retain the readout's click activation. - if enabled && typing.is_none() && (value_resp.clicked() || value_pressed) { - typing = Some((shown_value(spec, value).trim_start_matches('+').to_string(), 0, value_pressed)); - } - let value_released = ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary)); - // A release over the value keeps the field the press opened, however long the button was held - // (a press held past egui's click time ends without a click). A release elsewhere cancels the - // gesture, and a press that scrubs already dropped the field above. - let released_over_value = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| value_rect.contains(p)); - if value_released && !released_over_value && typing.as_ref().is_some_and(|(_, _, pressed)| *pressed) && !value_resp.clicked() { - typing = None; - ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id)); - ui.memory_mut(|m| m.surrender_focus(crate::text_field::id(&format!("sliderField:{}", spec.id)))); + ui.data_mut(|m| m.remove::(g.scrub_id())); } + update_typing_gesture(ui, &g, &value_resp, &mut typing); let mut out = SliderOut::default(); - // value ↔ track position: linear, except for the Kelvin temperature (`ControlSpec::position`) - let to_x = |v: f64| track_rect.left() + spec.position(v) as f32 * track_rect.width(); - let from_x = |x: f32| spec.value_at(f64::from((x - track_rect.left()) / track_rect.width())); // the thumb: a click there doesn't move the value, and resting on it says how to reset (the rest // of the track, which is clicked and dragged, gets no tooltip) - let on_thumb = |p: egui::Pos2| (p.x - to_x(value)).abs() <= THUMB_RING + 1.0; - if enabled && !resp.dragged() && resp.hover_pos().is_some_and(on_thumb) { + if enabled && !resp.dragged() && resp.hover_pos().is_some_and(|p| g.on_thumb(p)) { resp.clone().on_hover_text(reset_hint.as_str()); } - let mut v = value; - if let Some(mut pos) = scrub { - // scrubbing the value moves it as dragging the thumb would: the pointer's sideways travel - // over the track's width (a tenth with Shift), on the control's steps - if !scrub_started && value_resp.dragged() { - let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 }; - let dx = ui.input(|i| i.pointer.delta().x); - pos = (pos + f64::from(dx / track_rect.width()) * fine).clamp(0.0, 1.0); - } - let step = spec.step.max(1e-9); - let nv = ((spec.value_at(pos) / step).round() * step).clamp(spec.min, spec.max); - if (nv - value).abs() > 1e-12 { - out.value = Some(nv); - v = nv; - } - out.drag_started = scrub_started; - if value_resp.drag_stopped() { - out.drag_stopped = true; - ui.data_mut(|m| m.remove::(scrub_id)); - } else { - ui.data_mut(|m| m.insert_temp(scrub_id, pos)); - } - } else if let Some((mut text, frames, pressed)) = typing.take() { - // Keep the owned opening release focused without restarting its draft or selection. - // egui gives that release's click to the readout, so the field under it gives up the focus - // whatever frame it lands on (issue #1362): the field takes it back after drawing too. - let opening_release = pressed && value_released; - if opening_release { - ui.memory_mut(|m| m.request_focus(crate::text_field::id(&format!("sliderField:{}", spec.id)))); - } - let pressed = pressed && !value_released; - // a child over the value, so the field never moves the rows around it - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(value_rect).layout(egui::Layout::right_to_left(egui::Align::Center))); - // the old value is selected when the field takes the focus, so typing replaces it - let field = crate::text_field::TextField::singleline(&format!("sliderField:{}", spec.id), &mut text) - .font(t.font(12.5)) - .horizontal_align(egui::Align::RIGHT) - .width(value_rect.width()) - .margin(egui::Margin::ZERO) - .select_on_focus(true) - .begin_edit(frames == 0) - .show(&mut child); - if (frames < 2 || opening_release) && !matches!(field.ending, Some(crate::text_field::Ending::Return | crate::text_field::Ending::Escape)) { - field.response.request_focus(); - let frames = frames.saturating_add(1); - typing = Some((text.clone(), frames, pressed)); - ui.data_mut(|m| m.insert_temp(typing_id, (text, frames, pressed))); - } else if field.ending.is_some() || !field.editing || !enabled { - // Esc (or the slider turning off) keeps the old value; Return or clicking away applies - let cancelled = !enabled || field.cancelled(); - if let Some(nv) = typed_value(spec, &text).filter(|nv| !cancelled && (nv - value).abs() > 1e-12) { - out.value = Some(nv); - out.drag_started = true; - out.drag_stopped = true; - v = nv; - } - ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id)); - } else { - typing = Some((text.clone(), frames, pressed)); - ui.data_mut(|m| m.insert_temp(typing_id, (text, frames, pressed))); - } + if let Some(pos) = scrub { + scrub_value(ui, &g, &value_resp, pos, scrub_started, &mut out); + } else if let Some(draft) = typing.take() { + typing = edit_value(ui, &t, &g, draft, &mut out); } else if resp.double_clicked() || label_resp.double_clicked() { out.reset = true; out.value = Some(spec.default); - v = spec.default; } else { - if resp.drag_started() { - out.drag_started = true; - } - if (resp.dragged() || resp.drag_started()) - && let Some(p) = resp.interact_pointer_pos() - { - let fine = ui.input(|i| i.modifiers.shift); - let step = spec.step.max(1e-9); - let nv = if fine { - // a tenth of the pointer's movement along the track; the unrounded position is kept, so a - // slow drag adds up instead of rounding back to the same step every frame - let fine_id = id.with("fine-pos"); - let dx = ui.input(|i| i.pointer.delta().x); - let kept = ui.data(|m| m.get_temp::(fine_id)).filter(|&p| ((spec.value_at(p) / step).round() * step - value).abs() < step / 2.0); - let pos = (kept.unwrap_or_else(|| spec.position(value)) + f64::from(dx / track_rect.width()) * 0.1).clamp(0.0, 1.0); - ui.data_mut(|m| m.insert_temp(fine_id, pos)); - spec.value_at(pos) - } else { - from_x(p.x) - }; - let nv = ((nv / step).round() * step).clamp(spec.min, spec.max); - if (nv - value).abs() > 1e-12 { - out.value = Some(nv); - v = nv; - } - } else if resp.clicked() - && let Some(p) = resp.interact_pointer_pos() - // a click on the thumb leaves it there, so the first click of a double-click on it isn't a - // history step of its own before the reset - && !on_thumb(p) - { - let step = spec.step.max(1e-9); - let nv = ((from_x(p.x) / step).round() * step).clamp(spec.min, spec.max); - out.value = Some(nv); - v = nv; - out.drag_started = true; - out.drag_stopped = true; - } - if resp.drag_stopped() { - out.drag_stopped = true; - } - // a screen reader's increment / decrement (VoiceOver: Ctrl-Option-Up / Down) - let (inc, dec) = ui.input(|i| { - i.events.iter().fold((false, false), |(inc, dec), e| match e { - egui::Event::AccessKitActionRequest(r) if r.target_node == resp.id.accesskit_id() => { - (inc || r.action == egui::accesskit::Action::Increment, dec || r.action == egui::accesskit::Action::Decrement) - } - _ => (inc, dec), - }) - }); - // Arrow keys work with either a hovered or a focused slider. - if enabled && out.value.is_none() && typing.is_none() && (pointer_in_row || resp.has_focus() || inc || dec) { - let (up, down, shift) = ui.input_mut(|i| { - let shift = i.modifiers.shift; - let m = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }; - (i.consume_key(m, egui::Key::ArrowUp), i.consume_key(m, egui::Key::ArrowDown), shift) - }); - let (up, down) = (up || inc, down || dec); - if up || down { - let nv = nudged(spec, value, if up { 1.0 } else { -1.0 } * if shift { 5.0 } else { 1.0 }); - if (nv - value).abs() > 1e-12 { - out.value = Some(nv); - out.drag_started = true; - out.drag_stopped = true; - v = nv; - } - } - } - // Sideways scrolling (a trackpad swipe, a tilting wheel, Shift+wheel) adjusts the slider under - // the pointer, selected or not: the panel only scrolls vertically, so nothing else wants it. - // Only a mostly sideways movement counts, so a slightly diagonal swipe that scrolls the panel - // leaves the sliders it passes alone. Right raises the value, as dragging the thumb right does. - let side_id = id.with("side-rem"); - let (dx, dy) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y)); - if !enabled || typing.is_some() || out.value.is_some() || !pointer_in_row || dx.abs() <= dy.abs() { - ui.data_mut(|m| m.remove::(side_id)); - } else { - // the vertical part would scroll the panel and move the slider out from under the pointer - ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO); - let mut remainder = ui.data(|m| m.get_temp::(side_id)).unwrap_or(0.0); - let steps = wheel_nudges(&mut remainder, dx); - ui.data_mut(|m| m.insert_temp(side_id, remainder)); - let nv = nudged(spec, value, steps); - if steps != 0.0 && (nv - value).abs() > 1e-12 { - out.value = Some(nv); - out.drag_started = true; - out.drag_stopped = true; - v = nv; - } - } - // Wheel nudges require an explicitly selected slider, unlike regular panel scrolling. - // Consume only the vertical component so a trackpad's horizontal scroll still works. - // (`wheel_armed` is cleared above with its remainder whenever focus or the pointer leaves.) - if wheel_armed && enabled && typing.is_none() && out.value.is_none() && pointer_in_row { - let delta = ui.input_mut(|i| { - let dy = i.smooth_scroll_delta.y; - if dy != 0.0 { - i.smooth_scroll_delta.y = 0.0; - } - dy - }); - if delta != 0.0 { - let mut remainder = ui.data(|m| m.get_temp::(wheel_id)).unwrap_or(0.0); - let steps = wheel_nudges(&mut remainder, delta); - ui.data_mut(|m| m.insert_temp(wheel_id, remainder)); - if steps != 0.0 { - let nv = nudged(spec, value, steps); - if (nv - value).abs() > 1e-12 { - out.value = Some(nv); - out.drag_started = true; - out.drag_stopped = true; - v = nv; - } - } - } - } + pointer_edit(ui, &g, &resp, &mut out); + nudge_edit(ui, &g, &resp, pointer_in_row, &mut out); + side_scroll_edit(ui, &g, pointer_in_row, &mut out); + wheel_edit(ui, &g, wheel_armed, pointer_in_row, &mut out); } + let v = out.value.unwrap_or(value); // paint let hovered = resp.hovered() || resp.dragged(); let text_c = if enabled { t.text_label } else { t.text_disabled }; @@ -680,7 +465,7 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ } } let ring = THUMB_RING; - let tx = to_x(v); + let tx = g.to_x(v); paint_track(ui, track_rect, &spec.track, &t, tx, ring); let p = ui.painter(); let ring_c = if !enabled { @@ -698,6 +483,310 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ out } +/// A value being typed into a slider: the text, the frames it has been open, and whether the press that opened it is +/// still down. +type Draft = (String, u8, bool); + +/// What a slider is, as its interaction code needs it. +struct SliderLayout<'a> { + spec: &'a ControlSpec, + value: f64, + enabled: bool, + id: egui::Id, + /// where the thumb moves + track_rect: Rect, + /// the readout, where a value is typed or scrubbed + value_rect: Rect, +} + +impl SliderLayout<'_> { + fn typing_id(&self) -> egui::Id { + self.id.with("typing") + } + + fn scrub_id(&self) -> egui::Id { + self.id.with("scrub-pos") + } + + /// The text field a value is typed in. + fn field_id(&self) -> egui::Id { + crate::text_field::id(&format!("sliderField:{}", self.spec.id)) + } + + fn step(&self) -> f64 { + self.spec.step.max(1e-9) + } + + /// `v` on the control's steps and within its range. + fn snap(&self, v: f64) -> f64 { + ((v / self.step()).round() * self.step()).clamp(self.spec.min, self.spec.max) + } + + /// Whether `nv` is a different value from the one shown. + fn differs(&self, nv: f64) -> bool { + (nv - self.value).abs() > 1e-12 + } + + /// value ↔ track position: linear, except for the Kelvin temperature (`ControlSpec::position`) + fn to_x(&self, v: f64) -> f32 { + self.track_rect.left() + self.spec.position(v) as f32 * self.track_rect.width() + } + + fn value_at_x(&self, x: f32) -> f64 { + self.spec.value_at(f64::from((x - self.track_rect.left()) / self.track_rect.width())) + } + + /// Whether `p` is on the thumb. + fn on_thumb(&self, p: egui::Pos2) -> bool { + (p.x - self.to_x(self.value)).abs() <= THUMB_RING + 1.0 + } + + /// A change made in one go (a click, a key, a wheel step): it starts and stops the drag at once. + fn set_whole(&self, out: &mut SliderOut, nv: f64) { + out.value = Some(nv); + out.drag_started = true; + out.drag_stopped = true; + } + + /// Move to `nv` as a whole change, when it is a different value. + fn set_whole_if_changed(&self, out: &mut SliderOut, nv: f64) { + if self.differs(nv) { + self.set_whole(out, nv); + } + } +} + +/// Tell screen readers the slider's range, step and the value as shown ("+0.50", "5600"). +fn describe_slider(ui: &Ui, g: &SliderLayout, resp: &Response) { + let shown = shown_value(g.spec, g.value); + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_min_numeric_value(g.spec.min); + node.set_max_numeric_value(g.spec.max); + node.set_numeric_value_step(g.spec.step); + node.set_value(shown); + if g.enabled { + node.add_action(egui::accesskit::Action::Increment); + node.add_action(egui::accesskit::Action::Decrement); + } + }); +} + +/// A press on the readout that moves sideways starts a scrub: the edit the press opened is dropped. Returns whether +/// a scrub started this frame (`scrub` then holds its first position). +fn begin_scrub(ui: &mut Ui, g: &SliderLayout, value_resp: &Response, scrub: &mut Option, typing: &mut Option) -> bool { + if !g.enabled || scrub.is_some() || !value_resp.dragged() { + return false; + } + let (Some(origin), Some(p)) = ui.input(|i| (i.pointer.press_origin(), i.pointer.interact_pos())) else { return false }; + if (p.x - origin.x).abs() < SCRUB_START { + return false; + } + *typing = None; + ui.data_mut(|m| m.remove::(g.typing_id())); + ui.memory_mut(|m| m.surrender_focus(g.field_id())); + // the travel so far counts, so the value doesn't lag the pointer by the start distance + let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 }; + *scrub = Some((g.spec.position(g.value) + f64::from((p.x - origin.x) / g.track_rect.width()) * fine).clamp(0.0, 1.0)); + true +} + +/// Open the typed edit on a press or click of the readout; drop it when the press ends elsewhere. +fn update_typing_gesture(ui: &mut Ui, g: &SliderLayout, value_resp: &Response, typing: &mut Option) { + let value_pressed = value_resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)); + // Focus on the owned primary press before fast typing; retain the readout's click activation. + if g.enabled && typing.is_none() && (value_resp.clicked() || value_pressed) { + *typing = Some((shown_value(g.spec, g.value).trim_start_matches('+').to_string(), 0, value_pressed)); + } + let value_released = ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary)); + // A release over the value keeps the field the press opened, however long the button was held + // (a press held past egui's click time ends without a click). A release elsewhere cancels the + // gesture, and a press that scrubs already dropped the field above. + let released_over_value = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| g.value_rect.contains(p)); + if value_released && !released_over_value && typing.as_ref().is_some_and(|(_, _, pressed)| *pressed) && !value_resp.clicked() { + *typing = None; + ui.data_mut(|m| m.remove::(g.typing_id())); + ui.memory_mut(|m| m.surrender_focus(g.field_id())); + } +} + +/// Scrubbing the value moves it as dragging the thumb would: the pointer's sideways travel over the track's width (a +/// tenth with Shift), on the control's steps. `pos` is the unrounded track position reached so far. +fn scrub_value(ui: &mut Ui, g: &SliderLayout, value_resp: &Response, mut pos: f64, started: bool, out: &mut SliderOut) { + if !started && value_resp.dragged() { + let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 }; + let dx = ui.input(|i| i.pointer.delta().x); + pos = (pos + f64::from(dx / g.track_rect.width()) * fine).clamp(0.0, 1.0); + } + let nv = g.snap(g.spec.value_at(pos)); + if g.differs(nv) { + out.value = Some(nv); + } + out.drag_started = started; + if value_resp.drag_stopped() { + out.drag_stopped = true; + ui.data_mut(|m| m.remove::(g.scrub_id())); + } else { + ui.data_mut(|m| m.insert_temp(g.scrub_id(), pos)); + } +} + +/// The text field over the readout while a value is typed. Returns the draft to keep, if the edit goes on. +fn edit_value(ui: &mut Ui, t: &Tokens, g: &SliderLayout, draft: Draft, out: &mut SliderOut) -> Option { + let (mut text, frames, pressed) = draft; + // Keep the owned opening release focused without restarting its draft or selection. + // egui gives that release's click to the readout, so the field under it gives up the focus + // whatever frame it lands on (issue #1362): the field takes it back after drawing too. + let released = ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary)); + let opening_release = pressed && released; + if opening_release { + ui.memory_mut(|m| m.request_focus(g.field_id())); + } + let pressed = pressed && !released; + // a child over the value, so the field never moves the rows around it + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(g.value_rect).layout(egui::Layout::right_to_left(egui::Align::Center))); + // the old value is selected when the field takes the focus, so typing replaces it + let field = crate::text_field::TextField::singleline(&format!("sliderField:{}", g.spec.id), &mut text) + .font(t.font(12.5)) + .horizontal_align(egui::Align::RIGHT) + .width(g.value_rect.width()) + .margin(egui::Margin::ZERO) + .select_on_focus(true) + .begin_edit(frames == 0) + .show(&mut child); + if (frames < 2 || opening_release) && !matches!(field.ending, Some(crate::text_field::Ending::Return | crate::text_field::Ending::Escape)) { + field.response.request_focus(); + let draft = (text, frames.saturating_add(1), pressed); + ui.data_mut(|m| m.insert_temp(g.typing_id(), draft.clone())); + return Some(draft); + } + if field.ending.is_some() || !field.editing || !g.enabled { + // Esc (or the slider turning off) keeps the old value; Return or clicking away applies + let cancelled = !g.enabled || field.cancelled(); + if let Some(nv) = typed_value(g.spec, &text).filter(|nv| !cancelled && g.differs(*nv)) { + g.set_whole(out, nv); + } + ui.data_mut(|m| m.remove::(g.typing_id())); + return None; + } + let draft = (text, frames, pressed); + ui.data_mut(|m| m.insert_temp(g.typing_id(), draft.clone())); + Some(draft) +} + +/// Dragging the thumb or the track, or clicking the track. +fn pointer_edit(ui: &mut Ui, g: &SliderLayout, resp: &Response, out: &mut SliderOut) { + if resp.drag_started() { + out.drag_started = true; + } + if (resp.dragged() || resp.drag_started()) + && let Some(p) = resp.interact_pointer_pos() + { + let raw = if ui.input(|i| i.modifiers.shift) { fine_drag_value(ui, g) } else { g.value_at_x(p.x) }; + let nv = g.snap(raw); + if g.differs(nv) { + out.value = Some(nv); + } + } else if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + // a click on the thumb leaves it there, so the first click of a double-click on it isn't a + // history step of its own before the reset + && !g.on_thumb(p) + { + g.set_whole(out, g.snap(g.value_at_x(p.x))); + } + if resp.drag_stopped() { + out.drag_stopped = true; + } +} + +/// A tenth of the pointer's movement along the track; the unrounded position is kept, so a slow drag adds up instead +/// of rounding back to the same step every frame. +fn fine_drag_value(ui: &mut Ui, g: &SliderLayout) -> f64 { + let step = g.step(); + let fine_id = g.id.with("fine-pos"); + let dx = ui.input(|i| i.pointer.delta().x); + let kept = ui.data(|m| m.get_temp::(fine_id)).filter(|&p| ((g.spec.value_at(p) / step).round() * step - g.value).abs() < step / 2.0); + let pos = (kept.unwrap_or_else(|| g.spec.position(g.value)) + f64::from(dx / g.track_rect.width()) * 0.1).clamp(0.0, 1.0); + ui.data_mut(|m| m.insert_temp(fine_id, pos)); + g.spec.value_at(pos) +} + +/// A screen reader's increment / decrement (VoiceOver: Ctrl-Option-Up / Down) as `(increment, decrement)`. +fn accessibility_nudge(ui: &Ui, resp: &Response) -> (bool, bool) { + ui.input(|i| { + i.events.iter().fold((false, false), |(inc, dec), e| match e { + egui::Event::AccessKitActionRequest(r) if r.target_node == resp.id.accesskit_id() => { + (inc || r.action == egui::accesskit::Action::Increment, dec || r.action == egui::accesskit::Action::Decrement) + } + _ => (inc, dec), + }) + }) +} + +/// The arrow keys (with either a hovered or a focused slider; Shift: five steps) and a screen reader's actions. +fn nudge_edit(ui: &mut Ui, g: &SliderLayout, resp: &Response, pointer_in_row: bool, out: &mut SliderOut) { + let (inc, dec) = accessibility_nudge(ui, resp); + if !g.enabled || out.value.is_some() || !(pointer_in_row || resp.has_focus() || inc || dec) { + return; + } + let (up, down, shift) = ui.input_mut(|i| { + let shift = i.modifiers.shift; + let m = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }; + (i.consume_key(m, egui::Key::ArrowUp), i.consume_key(m, egui::Key::ArrowDown), shift) + }); + let (up, down) = (up || inc, down || dec); + if up || down { + let nv = nudged(g.spec, g.value, if up { 1.0 } else { -1.0 } * if shift { 5.0 } else { 1.0 }); + g.set_whole_if_changed(out, nv); + } +} + +/// Sideways scrolling (a trackpad swipe, a tilting wheel, Shift+wheel) adjusts the slider under +/// the pointer, selected or not: the panel only scrolls vertically, so nothing else wants it. +/// Only a mostly sideways movement counts, so a slightly diagonal swipe that scrolls the panel +/// leaves the sliders it passes alone. Right raises the value, as dragging the thumb right does. +fn side_scroll_edit(ui: &mut Ui, g: &SliderLayout, pointer_in_row: bool, out: &mut SliderOut) { + let side_id = g.id.with("side-rem"); + let (dx, dy) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y)); + if !g.enabled || out.value.is_some() || !pointer_in_row || dx.abs() <= dy.abs() { + ui.data_mut(|m| m.remove::(side_id)); + return; + } + // the vertical part would scroll the panel and move the slider out from under the pointer + ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO); + let mut remainder = ui.data(|m| m.get_temp::(side_id)).unwrap_or(0.0); + let steps = wheel_nudges(&mut remainder, dx); + ui.data_mut(|m| m.insert_temp(side_id, remainder)); + if steps != 0.0 { + g.set_whole_if_changed(out, nudged(g.spec, g.value, steps)); + } +} + +/// Wheel nudges require an explicitly selected (armed) slider, unlike regular panel scrolling. +/// Consume only the vertical component so a trackpad's horizontal scroll still works. +fn wheel_edit(ui: &mut Ui, g: &SliderLayout, armed: bool, pointer_in_row: bool, out: &mut SliderOut) { + if !armed || !g.enabled || out.value.is_some() || !pointer_in_row { + return; + } + let delta = ui.input_mut(|i| { + let dy = i.smooth_scroll_delta.y; + if dy != 0.0 { + i.smooth_scroll_delta.y = 0.0; + } + dy + }); + if delta == 0.0 { + return; + } + let wheel_id = g.id.with("wheel-rem"); + let mut remainder = ui.data(|m| m.get_temp::(wheel_id)).unwrap_or(0.0); + let steps = wheel_nudges(&mut remainder, delta); + ui.data_mut(|m| m.insert_temp(wheel_id, remainder)); + if steps != 0.0 { + g.set_whole_if_changed(out, nudged(g.spec, g.value, steps)); + } +} + /// Convert vertical wheel movement to bounded slider steps, keeping sub-notch /// trackpad movement between frames without amplifying a single large event. One mouse-wheel /// notch ([`crate::theme::WHEEL_LINE_POINTS`]) is one step. From f6489e60d8a4281793740488e303f1fa39b8209b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 03:20:48 +0000 Subject: [PATCH 37/39] refactor(lightcraft): import prefs and GPU crash names explicitly --- apps/lightcraft/src/gpu_crash.rs | 2 +- apps/lightcraft/src/prefs.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/lightcraft/src/gpu_crash.rs b/apps/lightcraft/src/gpu_crash.rs index 1656d88e3..3b7422315 100644 --- a/apps/lightcraft/src/gpu_crash.rs +++ b/apps/lightcraft/src/gpu_crash.rs @@ -1,5 +1,5 @@ use super::prefs::config_dir; -use super::*; +use super::window_gpu; /// The window's renderer (egui-wgpu): eframe's defaults, with LightCraft's backend choice — DX12 /// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136: diff --git a/apps/lightcraft/src/prefs.rs b/apps/lightcraft/src/prefs.rs index 0800e6f3e..5701bf161 100644 --- a/apps/lightcraft/src/prefs.rs +++ b/apps/lightcraft/src/prefs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LibraryProblem, LightcraftApp, UiState}; /// Where the system keeps monitor profiles, for the Choose Profile dialog to start in: the user's /// own profiles first (Linux: colord / DisplayCAL; macOS: ColorSync), then the system's. From 6e54232557c0ed97fcdb297b73ca4ab115af2034 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 03:20:48 +0000 Subject: [PATCH 38/39] test: factor long UI and MCP scenarios into helpers --- crates/mcp/tests/it/e2e.rs | 99 ++++++---- crates/ui-egui/src/headless/tests.rs | 189 +++++++++++-------- crates/ui-egui/src/i18n/tests.rs | 112 ++++++----- crates/ui-egui/src/render/thumbnail_tests.rs | 77 +++++--- crates/ui-egui/src/tests/open_lightroom.rs | 31 ++- crates/ui-egui/src/tests/preview_limit.rs | 120 +++++++----- crates/ui-egui/src/tests/slideshow.rs | 80 +++++--- 7 files changed, 438 insertions(+), 270 deletions(-) diff --git a/crates/mcp/tests/it/e2e.rs b/crates/mcp/tests/it/e2e.rs index 03d77c7e2..2a8db44c7 100644 --- a/crates/mcp/tests/it/e2e.rs +++ b/crates/mcp/tests/it/e2e.rs @@ -246,33 +246,13 @@ impl Backend for HoldAfterFirst { } } -/// Real stdio framing and real atomic exports: progress, ping during a batch, cancellation -/// between complete photos, unrelated output preservation, and a usable session afterwards. -/// Both exports wait after their first photo ([`HoldAfterFirst`]) until the ping is answered or -/// the cancel is read, so neither can finish first on a fast machine. -#[test] -fn export_progress_and_cancel() { - let dir = std::env::temp_dir().join(format!("lc-mcp-progress-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let (input, mut send) = std::io::pipe().unwrap(); - let (output, write) = std::io::pipe().unwrap(); - let hold = Arc::new(AtomicBool::new(true)); - let backend = HoldAfterFirst { inner: file_backed_photos(&dir), hold: hold.clone() }; - let server = std::thread::spawn(move || Server::new(Box::new(backend)).serve(BufReader::new(input), write).unwrap()); - let (tx, rx) = std::sync::mpsc::channel(); - let reader = std::thread::spawn(move || { - for line in BufReader::new(output).lines() { - let value: Value = serde_json::from_str(&line.unwrap()).unwrap(); - if tx.send(value).is_err() { - break; - } - } - }); - let next = || rx.recv_timeout(Duration::from_secs(30)).expect("MCP reply/progress"); - writeln!(send, "{}", call(1, "query_photos", json!({"limit":8}))).unwrap(); - let query = next(); - let photos = query["result"]["structuredContent"]["photos"].as_array().unwrap(); +type Rx = std::sync::mpsc::Receiver; + +fn next_msg(rx: &Rx) -> Value { + rx.recv_timeout(Duration::from_secs(30)).expect("MCP reply/progress") +} + +fn assert_query_photos(photos: &[Value]) { assert_eq!(photos.len(), 8); // Real files, real metadata: eight distinct 1200×800 PNGs probed from disk, not synthesized. let names: std::collections::HashSet<&str> = photos.iter().map(|p| p["fileName"].as_str().unwrap()).collect(); @@ -281,13 +261,15 @@ fn export_progress_and_cancel() { assert_eq!((p["width"].as_u64(), p["height"].as_u64()), (Some(1200), Some(800)), "{p}"); assert_eq!(p["format"], "PNG", "{p}"); } - let ids: Vec<_> = photos.iter().map(|p| p["id"].clone()).collect(); +} + +fn run_complete_export(send: &mut impl Write, rx: &Rx, hold: &AtomicBool, ids: &[Value], dir: &std::path::Path) { let mut request = call(2, "export", json!({"ids":ids,"dir":dir.join("complete"),"longEdge":1200,"format":"png"})); request["params"]["_meta"] = json!({"progressToken":"photos","io.modelcontextprotocol/protocolVersion":"2026-07-28"}); writeln!(send, "{request}").unwrap(); let (mut last, mut notes, mut asked, mut answered) = (-1.0, 0, false, false); loop { - let value = next(); + let value = next_msg(rx); if value["method"] == "notifications/progress" { assert_eq!(value["params"]["progressToken"], "photos"); let progress = value["params"]["progress"].as_f64().unwrap(); @@ -312,17 +294,16 @@ fn export_progress_and_cancel() { } assert!(notes >= 2 && answered, "{notes} progress messages; ping answered {answered}"); assert_eq!(last, 8.0); - let out = dir.join("cancelled"); - std::fs::create_dir_all(&out).unwrap(); - let decoy = out.join("photo999.png"); - std::fs::write(&decoy, b"unrelated existing output").unwrap(); +} + +fn run_cancelled_export(send: &mut impl Write, rx: &Rx, hold: &AtomicBool, ids: &[Value], out: &std::path::Path) { // this export waits after its first photo until the cancel below is read hold.store(true, Ordering::SeqCst); let mut request = call(4, "command_run", json!({"id":"app.export","params":{"ids":ids,"dir":out,"longEdge":1200,"format":"png"}})); request["params"]["_meta"] = json!({"progressToken":44}); writeln!(send, "{request}").unwrap(); loop { - let value = next(); + let value = next_msg(rx); assert_ne!(value["id"], 4, "must be cancellable before completion: {value}"); assert_eq!(value["params"]["progressToken"], 44, "{value}"); if value["params"]["progress"].as_f64().unwrap() > 0.0 { @@ -332,7 +313,7 @@ fn export_progress_and_cancel() { writeln!(send, "{}", json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":4}})).unwrap(); writeln!(send, "{}", call(5, "doc_inspect", json!({}))).unwrap(); loop { - let value = next(); + let value = next_msg(rx); if value["method"] == "notifications/progress" { continue; } @@ -340,13 +321,19 @@ fn export_progress_and_cancel() { assert_eq!(value["result"]["isError"], false); break; } - assert_eq!(std::fs::read(&decoy).unwrap(), b"unrelated existing output"); - let photos: Vec<_> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().path()).filter(|p| *p != decoy).collect(); +} + +fn assert_cancelled_output(out: &std::path::Path, decoy: &std::path::Path) { + assert_eq!(std::fs::read(decoy).unwrap(), b"unrelated existing output"); + let photos: Vec<_> = std::fs::read_dir(out).unwrap().map(|e| e.unwrap().path()).filter(|p| p != decoy).collect(); assert_eq!(photos.len(), 1, "only the photo completed before the cancel remains: {photos:?}"); for path in &photos { let d = lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).expect("complete image, no partial/temp file"); assert_eq!((d.width, d.height), (1200, 800), "{}", path.display()); } +} + +fn assert_complete_output(dir: &std::path::Path) { // The finished export's eight outputs are real 1200×800 PNGs, one per distinct original. let complete: Vec<_> = std::fs::read_dir(dir.join("complete")).unwrap().map(|e| e.unwrap().path()).collect(); assert_eq!(complete.len(), 8, "earlier export retained: {complete:?}"); @@ -359,6 +346,44 @@ fn export_progress_and_cancel() { exported.insert(bytes); } assert_eq!(exported.len(), 8, "eight distinct outputs, not copies of one export"); +} + +/// Real stdio framing and real atomic exports: progress, ping during a batch, cancellation +/// between complete photos, unrelated output preservation, and a usable session afterwards. +/// Both exports wait after their first photo ([`HoldAfterFirst`]) until the ping is answered or +/// the cancel is read, so neither can finish first on a fast machine. +#[test] +fn export_progress_and_cancel() { + let dir = std::env::temp_dir().join(format!("lc-mcp-progress-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (input, mut send) = std::io::pipe().unwrap(); + let (output, write) = std::io::pipe().unwrap(); + let hold = Arc::new(AtomicBool::new(true)); + let backend = HoldAfterFirst { inner: file_backed_photos(&dir), hold: hold.clone() }; + let server = std::thread::spawn(move || Server::new(Box::new(backend)).serve(BufReader::new(input), write).unwrap()); + let (tx, rx) = std::sync::mpsc::channel(); + let reader = std::thread::spawn(move || { + for line in BufReader::new(output).lines() { + let value: Value = serde_json::from_str(&line.unwrap()).unwrap(); + if tx.send(value).is_err() { + break; + } + } + }); + writeln!(send, "{}", call(1, "query_photos", json!({"limit":8}))).unwrap(); + let query = next_msg(&rx); + let photos = query["result"]["structuredContent"]["photos"].as_array().unwrap(); + assert_query_photos(photos); + let ids: Vec<_> = photos.iter().map(|p| p["id"].clone()).collect(); + run_complete_export(&mut send, &rx, &hold, &ids, &dir); + let out = dir.join("cancelled"); + std::fs::create_dir_all(&out).unwrap(); + let decoy = out.join("photo999.png"); + std::fs::write(&decoy, b"unrelated existing output").unwrap(); + run_cancelled_export(&mut send, &rx, &hold, &ids, &out); + assert_cancelled_output(&out, &decoy); + assert_complete_output(&dir); drop(send); // The fixture's files stay in place until the server (which owns the session) and the reader // have finished: the session reads the originals; the output is what the checks above read. diff --git a/crates/ui-egui/src/headless/tests.rs b/crates/ui-egui/src/headless/tests.rs index f38ed636a..13bd7a2ae 100644 --- a/crates/ui-egui/src/headless/tests.rs +++ b/crates/ui-egui/src/headless/tests.rs @@ -2775,6 +2775,109 @@ fn browse_a_local_folder() { let _ = std::fs::remove_dir_all(&dir); } +fn hide_rects(h: &mut Headless) -> Vec<(String, Vec)> { + let w = h.request("ui.widgets", json!({"filter": "source:local:"}), Duration::from_secs(10)); + w["result"] + .as_array() + .unwrap() + .iter() + .map(|x| (x["id"].as_str().unwrap().to_string(), x["rect"].as_array().unwrap().iter().filter_map(|v| v.as_f64()).collect())) + .collect() +} + +fn hide_ids(h: &mut Headless) -> Vec { + hide_rects(h).into_iter().map(|(id, _)| id).collect() +} + +fn hide_menu(h: &mut Headless) -> Vec { + let w = h.request("ui.widgets", json!({"filter": "local:restoreHidden:"}), Duration::from_secs(10)); + w["result"].as_array().unwrap().iter().map(|x| x["id"].as_str().unwrap().to_string()).collect() +} + +/// Hides `path` through the command and settles the frames. +fn hide_path(h: &mut Headless, path: &str) { + let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), Duration::from_secs(10)); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); +} + +/// Keeps and browses `path` the way Browse Folder… does; returns whether anything was hidden before. +fn hide_browse_and_hide(h: &mut Headless, path: &str, size: [f32; 2]) -> bool { + let t = Duration::from_secs(10); + // Browse Folder… browses the picked folder and keeps it in Local within one frame; a + // request runs frames, so keep it first (no frame sees it browsed but not yet kept) + let r = h.request("engine.execute", json!({"command": "local.addRoot", "params": {"path": path}}), t); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": path}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let row = format!("source:local:{path}"); + assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "the browsed folder is listed: {:?}", hide_ids(h)); + // the "Show hidden locations" row appears only while something is hidden + let hidden_before = !h.app.ui.hidden_locations.is_empty(); + assert_eq!(hide_ids(h).iter().any(|i| i == "source:local:restoreHidden"), hidden_before); + // hide it while it is still being browsed + hide_path(h, path); + // give any listing a reveal would wait for time to arrive + for _ in 0..20 { + h.step(); + std::thread::sleep(Duration::from_millis(5)); + } + h.settle(SETTLE); + let after = hide_rects(h); + let after_ids: Vec<&str> = after.iter().map(|(id, _)| id.as_str()).collect(); + assert!(!after_ids.contains(&format!("source:local:{path}").as_str()), "{after_ids:?}"); + let restore = after.iter().find(|(id, _)| id == "source:local:restoreHidden").map(|(_, r)| r.clone()); + let restore = restore.unwrap_or_else(|| panic!("no restore row: {after_ids:?}")); + 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"); + hidden_before +} + +/// 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). +fn hide_second_place_restores_alone(h: &mut Headless, path: &str, path2: &str) { + let t = Duration::from_secs(10); + 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); + assert!(hide_menu(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); + let items = hide_menu(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!(hide_ids(h).iter().any(|i| i == "source:local:restoreHidden"), "still something to restore"); +} + +fn hide_restore_all_then_single(h: &mut Headless, path: &str, path2: &str, hidden_before: bool) { + let t = Duration::from_secs(10); + // two hidden again: Restore All brings every one back + hide_path(h, path); + 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!(!hide_ids(h).iter().any(|i| i == "source:local:restoreHidden"), "nothing left to restore"); + // one hidden: the row brings it back itself, no list + hide_path(h, path2); + assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); + h.settle(SETTLE); + assert!(hide_menu(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!(hide_ids(h).contains(&format!("source:local:{path}"))); + } +} + /// Local: Remove from Local hides a sidebar location (nothing on disk changes) and the /// "Show hidden locations" row puts it back. The folder is browsed and kept the way Browse /// Folder… does it, in the temporary folder — on Windows beneath Home, whose tree must not @@ -2788,96 +2891,16 @@ fn local_location_can_be_hidden_and_restored() { let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let path = dir.to_string_lossy().to_string(); - let rects = |h: &mut Headless| -> Vec<(String, Vec)> { - let w = h.request("ui.widgets", json!({"filter": "source:local:"}), t); - w["result"] - .as_array() - .unwrap() - .iter() - .map(|x| (x["id"].as_str().unwrap().to_string(), x["rect"].as_array().unwrap().iter().filter_map(|v| v.as_f64()).collect())) - .collect() - }; - let ids = |h: &mut Headless| -> Vec { rects(h).into_iter().map(|(id, _)| id).collect() }; h.request("ui.set", json!({"leftPanel": true}), t); h.hide_home_above(&dir); - // Browse Folder… browses the picked folder and keeps it in Local within one frame; a - // request runs frames, so keep it first (no frame sees it browsed but not yet kept) - let r = h.request("engine.execute", json!({"command": "local.addRoot", "params": {"path": path}}), t); - assert_eq!(r["ok"], true, "{r}"); - let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": path}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - let row = format!("source:local:{path}"); - assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "the browsed folder is listed: {:?}", ids(&mut h)); - // the "Show hidden locations" row appears only while something is hidden - let hidden_before = !h.app.ui.hidden_locations.is_empty(); - assert_eq!(ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), hidden_before); - // hide it while it is still being browsed - let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - // give any listing a reveal would wait for time to arrive - for _ in 0..20 { - h.step(); - std::thread::sleep(Duration::from_millis(5)); - } - h.settle(SETTLE); - let after = rects(&mut h); - let after_ids: Vec<&str> = after.iter().map(|(id, _)| id.as_str()).collect(); - assert!(!after_ids.contains(&format!("source:local:{path}").as_str()), "{after_ids:?}"); - let restore = after.iter().find(|(id, _)| id == "source:local:restoreHidden").map(|(_, r)| r.clone()); - let restore = restore.unwrap_or_else(|| panic!("no restore row: {after_ids:?}")); - 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"); + let hidden_before = hide_browse_and_hide(&mut h, &path, size); 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 { - 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); - 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}"))); - } + hide_second_place_restores_alone(&mut h, &path, &path2); + hide_restore_all_then_single(&mut h, &path, &path2, hidden_before); let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir2); } diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 2a44072f0..77bbc14cb 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -214,6 +214,32 @@ fn switching_language_translates_and_restores() { assert_eq!(tr("Exposure"), "Exposure"); } +fn assert_format_specs(language: &Locale) { + let text = tr_format!("{count} smart preview{} · {:.1} MB", "s", 12.345, count = 3); + assert!(text.contains("12.3") && !text.contains("12.34"), "{language:?}: {text}"); + let merging = tr_format!("Merging… {stage} {:.0}%", 42.4, stage = "Aligning"); + assert!(merging.contains("42%"), "{language:?}: {merging}"); + let delta = tr_format!("{label} {d:+} on every selected photo", label = "Exposure", d = 5); + assert!(delta.contains("+5"), "{language:?}: {delta}"); + let reading = tr_format!("Reading photos… {} of {}", 3, 10); + let (three, ten) = (reading.find('3'), reading.find("10")); + assert!(three.is_some() && ten.is_some(), "{language:?}: {reading}"); +} + +fn assert_format_values_kept(language: &Locale) { + let name = "Color 1234.56 {title} 日本語"; + let added = tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22); + assert!(added.contains(name) && added.contains("22"), "{language:?}: {added}"); + for n in [0, 1, 2, 5, 22] { + let imported = tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }); + assert!(imported.contains(&n.to_string()), "{language:?}: {imported}"); + } + // The trailing argument is queue progress, not an English plural suffix. + let queued = tr_format!(" · {queued} waiting", queued = 5); + let denoising = tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4); + assert!(denoising.contains("IMG.CR3") && denoising.contains('5'), "{language:?}: {denoising}"); +} + /// Format strings carry their values in every language, and a language without a translation /// falls back to the English format rather than dropping the values. #[test] @@ -228,26 +254,8 @@ fn formats_render_in_every_language() { // text (an album name with braces and CJK) and counts are never dropped. for language in Locale::ALL { set_language(*language); - let text = tr_format!("{count} smart preview{} · {:.1} MB", "s", 12.345, count = 3); - assert!(text.contains("12.3") && !text.contains("12.34"), "{language:?}: {text}"); - let merging = tr_format!("Merging… {stage} {:.0}%", 42.4, stage = "Aligning"); - assert!(merging.contains("42%"), "{language:?}: {merging}"); - let delta = tr_format!("{label} {d:+} on every selected photo", label = "Exposure", d = 5); - assert!(delta.contains("+5"), "{language:?}: {delta}"); - let reading = tr_format!("Reading photos… {} of {}", 3, 10); - let (three, ten) = (reading.find('3'), reading.find("10")); - assert!(three.is_some() && ten.is_some(), "{language:?}: {reading}"); - let name = "Color 1234.56 {title} 日本語"; - let added = tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22); - assert!(added.contains(name) && added.contains("22"), "{language:?}: {added}"); - for n in [0, 1, 2, 5, 22] { - let imported = tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }); - assert!(imported.contains(&n.to_string()), "{language:?}: {imported}"); - } - // The trailing argument is queue progress, not an English plural suffix. - let queued = tr_format!(" · {queued} waiting", queued = 5); - let denoising = tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4); - assert!(denoising.contains("IMG.CR3") && denoising.contains('5'), "{language:?}: {denoising}"); + assert_format_specs(language); + assert_format_values_kept(language); } set_language(Locale::En); assert_eq!(tr_format!("Reading photos… {} of {}", 3, 10), "Reading photos… 3 of 10"); @@ -1128,24 +1136,7 @@ fn a_dropped_photos_notice_names_every_outcome_of_the_import_and_is_silent_for_d let _ = std::fs::remove_dir_all(dir); } -/// Polish: the menu and control channel select it, dates read day.month.year, and every count -/// reads right for any number. Polish has three plural forms (1 zdjęcie, 2 zdjęcia, 5 zdjęć), so -/// counts follow their noun ("Zaimportowane zdjęcia: 22") instead of a form that fits only some. -#[test] -fn polish_switches_formats_dates_and_reads_every_count() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.polish", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::Pl); - assert_eq!(Locale::Pl.name(), "Polski"); - assert_eq!(crate::menubar::checked(&app, "app.language.polish"), Some(true)); - assert_eq!(tr("File"), "Plik"); - assert_eq!(tr("Exposure"), "Ekspozycja"); - assert_eq!(date_group_label("2026-09-20", false), "Niedziela, 20.09.2026"); - assert_eq!(date_group_label("2026-09-20", true), "Niedziela, 20"); - assert_eq!(date_group_label("2026-09", false), "09.2026"); - assert_eq!(date_group_label("2026", false), "2026"); - assert_eq!(display_time("2026-09-20T16:04:05"), "Niedziela, 20.09.2026 16:04:05"); +fn assert_polish_counts() { for n in [0, 1, 2, 3, 4, 5, 12, 21, 22, 25, 111] { let s = if n == 1 { "" } else { "s" }; assert_eq!(tr_format!("Imported {} photo{}", n, s), format!("Zaimportowane zdjęcia: {n}")); @@ -1159,20 +1150,20 @@ fn polish_switches_formats_dates_and_reads_every_count() { assert_eq!(tr_format!("Delete {n} Photos", n = n), format!("Usuń zdjęcia ({n})")); assert_eq!(tr_format!("{n} preset{}", s, n = n), format!("Ustawienia: {n}")); } - let name = String::from("Color 1234.56 {title} 日本語"); - assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Dodano do „{name}”: 22")); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::Pl); - let ctx = egui::Context::default(); +} + +fn assert_polish_selected_by_tags(app: &mut crate::LightcraftApp, ctx: &egui::Context) { + use crate::control::{ControlRequest, Outcome}; for tag in ["pl", "pl-PL", "pl_PL.UTF-8", "PL-pl"] { assert_eq!(Locale::parse_tag(tag), Some(Locale::Pl), "{tag}"); let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; + let Outcome::Done(reply) = crate::control::handle(app, ctx, &request) else { panic!("expected reply") }; assert_eq!(reply["ok"], true); assert_eq!(language(), Locale::Pl); } - let text = painted_text(&ctx, &mut app, Locale::Pl); - assert!(text.contains("Plik") && text.contains("Ekspozycja"), "{text}"); +} + +fn assert_polish_glyphs(ctx: &egui::Context) { ctx.fonts_mut(|fonts| { for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { let font = egui::FontId::new(13.0, family); @@ -1181,5 +1172,34 @@ fn polish_switches_formats_dates_and_reads_every_count() { } } }); +} + +/// Polish: the menu and control channel select it, dates read day.month.year, and every count +/// reads right for any number. Polish has three plural forms (1 zdjęcie, 2 zdjęcia, 5 zdjęć), so +/// counts follow their noun ("Zaimportowane zdjęcia: 22") instead of a form that fits only some. +#[test] +fn polish_switches_formats_dates_and_reads_every_count() { + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.polish", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::Pl); + assert_eq!(Locale::Pl.name(), "Polski"); + assert_eq!(crate::menubar::checked(&app, "app.language.polish"), Some(true)); + assert_eq!(tr("File"), "Plik"); + assert_eq!(tr("Exposure"), "Ekspozycja"); + assert_eq!(date_group_label("2026-09-20", false), "Niedziela, 20.09.2026"); + assert_eq!(date_group_label("2026-09-20", true), "Niedziela, 20"); + assert_eq!(date_group_label("2026-09", false), "09.2026"); + assert_eq!(date_group_label("2026", false), "2026"); + assert_eq!(display_time("2026-09-20T16:04:05"), "Niedziela, 20.09.2026 16:04:05"); + assert_polish_counts(); + let name = String::from("Color 1234.56 {title} 日本語"); + assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Dodano do „{name}”: 22")); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::Pl); + let ctx = egui::Context::default(); + assert_polish_selected_by_tags(&mut app, &ctx); + let text = painted_text(&ctx, &mut app, Locale::Pl); + assert!(text.contains("Plik") && text.contains("Ekspozycja"), "{text}"); + assert_polish_glyphs(&ctx); set_language(Locale::En); } diff --git a/crates/ui-egui/src/render/thumbnail_tests.rs b/crates/ui-egui/src/render/thumbnail_tests.rs index eaf4e8f2f..2fcd6c5ab 100644 --- a/crates/ui-egui/src/render/thumbnail_tests.rs +++ b/crates/ui-egui/src/render/thumbnail_tests.rs @@ -774,66 +774,81 @@ fn undo_to_existing_texture_supersedes_pending_edit() { assert_eq!(app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels, original.pixels); } -#[test] -fn cells_at_different_thumbnail_buckets_keep_one_pending_request() { - let (mut app, work, ctx) = fixture(); +/// First request: other buckets must not replace the pending job. Returns the pending key. +fn thumb_buckets_pending_request(app: &mut crate::LightcraftApp, work: &std::rc::Rc>, ctx: &egui::Context) -> u64 { let id = PhotoId(1); let slot = Slot::Thumb(id); // A print page's cells show the same photo at different sizes. Before any texture arrives, // each frame's other bucket used to replace (and restart) the pending job. - crate::panels::grid::request_thumb(&mut app, id, 256, 10); + crate::panels::grid::request_thumb(app, id, 256, 10); let built = app.renderer.thumb_jobs_built; assert_eq!(built, 1); - let (submitted, pending) = take_job(&work); + let (submitted, pending) = take_job(work); assert_eq!(submitted, slot); for _ in 0..12 { - crate::panels::grid::request_thumb(&mut app, id, 512, 10); - crate::panels::grid::request_thumb(&mut app, id, 384, 8); - crate::panels::grid::request_thumb(&mut app, id, 256, 10); + crate::panels::grid::request_thumb(app, id, 512, 10); + crate::panels::grid::request_thumb(app, id, 384, 8); + crate::panels::grid::request_thumb(app, id, 256, 10); } assert_eq!(app.renderer.thumb_jobs_built, built, "another bucket must not replace the pending thumbnail"); assert!(work.borrow().jobs.is_empty(), "the pending render is not restarted"); assert_eq!(app.renderer.pending[&slot].0, pending.key); let pending_key = pending.key; - finish(&mut app, &work, &ctx, slot, pending.run()); + finish(app, work, ctx, slot, pending.run()); assert_eq!(app.renderer.textures[&slot].key, pending_key); assert_eq!(app.renderer.thumb_inputs[&id].1, 256, "the completed request's bucket is remembered"); assert!(!app.renderer.is_pending(slot)); + pending_key +} +/// A larger bucket still upgrades; smaller cells wait behind it. Returns the larger key. +fn thumb_buckets_larger_upgrades( + app: &mut crate::LightcraftApp, + work: &std::rc::Rc>, + ctx: &egui::Context, + pending_key: u64, +) -> u64 { + let id = PhotoId(1); + let slot = Slot::Thumb(id); // With a completed 256 thumbnail, the larger cell asks for its bucket once; the smaller // cells then wait behind it instead of replacing it back every frame. let built = app.renderer.thumb_jobs_built; - crate::panels::grid::request_thumb(&mut app, id, 512, 10); + crate::panels::grid::request_thumb(app, id, 512, 10); assert_eq!(app.renderer.thumb_jobs_built, built + 1, "a larger bucket still upgrades"); - let (submitted, larger) = take_job(&work); + let (submitted, larger) = take_job(work); assert_eq!(submitted, slot); assert_ne!(larger.key, pending_key); let larger_key = app.renderer.pending[&slot].0; assert_eq!(larger_key, larger.key); for _ in 0..12 { - crate::panels::grid::request_thumb(&mut app, id, 512, 10); - crate::panels::grid::request_thumb(&mut app, id, 384, 10); - crate::panels::grid::request_thumb(&mut app, id, 256, 10); + crate::panels::grid::request_thumb(app, id, 512, 10); + crate::panels::grid::request_thumb(app, id, 384, 10); + crate::panels::grid::request_thumb(app, id, 256, 10); } assert_eq!(app.renderer.thumb_jobs_built, built + 1, "smaller cells keep the larger render in flight"); assert!(work.borrow().jobs.is_empty(), "the larger render is not restarted"); assert_eq!(app.renderer.pending[&slot].0, larger_key); assert_eq!(app.renderer.thumb_inputs[&id].1, 512, "the in-flight bucket is still the larger one"); - finish(&mut app, &work, &ctx, slot, larger.run()); + finish(app, work, ctx, slot, larger.run()); assert_eq!(app.renderer.textures[&slot].key, larger_key); assert_eq!(app.renderer.thumb_inputs[&id].1, 512); assert!(!app.renderer.is_pending(slot)); + larger_key +} +fn thumb_buckets_largest_serves_smaller(app: &mut crate::LightcraftApp, work: &std::rc::Rc>, larger_key: u64) { + let id = PhotoId(1); + let slot = Slot::Thumb(id); // Once the largest visible bucket is complete, every smaller cell reuses those pixels. // The 8x8 fixture texture cannot show its requested edge, so the remembered bucket, key // and photo identity are what prove no render was built. Repeated frames at all sizes // (the old bug alternated 256/512 forever) must settle with nothing further requested. let built = app.renderer.thumb_jobs_built; for _ in 0..24 { - crate::panels::grid::request_thumb(&mut app, id, 256, 10); - crate::panels::grid::request_thumb(&mut app, id, 384, 10); - crate::panels::grid::request_thumb(&mut app, id, 512, 10); - crate::panels::grid::request_thumb(&mut app, id, 384, 8); + crate::panels::grid::request_thumb(app, id, 256, 10); + crate::panels::grid::request_thumb(app, id, 384, 10); + crate::panels::grid::request_thumb(app, id, 512, 10); + crate::panels::grid::request_thumb(app, id, 384, 8); } assert_eq!(app.renderer.thumb_jobs_built, built, "completed larger thumbnails serve smaller cells"); assert!(work.borrow().jobs.is_empty()); @@ -841,7 +856,16 @@ fn cells_at_different_thumbnail_buckets_keep_one_pending_request() { assert_eq!(app.renderer.textures[&slot].key, larger_key); assert_eq!(app.renderer.thumb_inputs[&id].1, 512); assert_eq!(app.renderer.thumb_inputs[&id].2, larger_key); +} +fn thumb_buckets_edit_requests_again( + app: &mut crate::LightcraftApp, + work: &std::rc::Rc>, + ctx: &egui::Context, + larger_key: u64, +) { + let id = PhotoId(1); + let slot = Slot::Thumb(id); // The renderer remembers the photo through a `Weak` only. That is enough for // `Catalog`'s `Arc::make_mut` to copy the photo on edit (std: an existing weak pointer // forces a new allocation even with a single strong reference), so the identity below is @@ -861,18 +885,27 @@ fn cells_at_different_thumbnail_buckets_keep_one_pending_request() { assert_eq!(remembered.strong_count(), 0, "the renderer's Weak now points at the old, unowned allocation"); assert_eq!(Arc::strong_count(current), 1, "the edit left one strong owner; no extra Arc was needed"); let built = app.renderer.thumb_jobs_built; - crate::panels::grid::request_thumb(&mut app, id, 256, 10); + crate::panels::grid::request_thumb(app, id, 256, 10); assert_eq!(app.renderer.thumb_jobs_built, built + 1, "an edited photo is requested again"); - let (submitted, edited) = take_job(&work); + let (submitted, edited) = take_job(work); assert_eq!(submitted, slot); assert_ne!(edited.key, larger_key); let edited_key = edited.key; - finish(&mut app, &work, &ctx, slot, edited.run()); + finish(app, work, ctx, slot, edited.run()); assert_eq!(app.renderer.textures[&slot].key, edited_key); assert_eq!(app.renderer.thumb_inputs[&id].1, 256); assert!(!app.renderer.is_pending(slot)); } +#[test] +fn cells_at_different_thumbnail_buckets_keep_one_pending_request() { + let (mut app, work, ctx) = fixture(); + let pending_key = thumb_buckets_pending_request(&mut app, &work, &ctx); + let larger_key = thumb_buckets_larger_upgrades(&mut app, &work, &ctx, pending_key); + thumb_buckets_largest_serves_smaller(&mut app, &work, larger_key); + thumb_buckets_edit_requests_again(&mut app, &work, &ctx, larger_key); +} + /// The remembered larger bucket is not a picture by itself: once its texture is evicted, a /// smaller cell must render again rather than trust the stale record. #[test] diff --git a/crates/ui-egui/src/tests/open_lightroom.rs b/crates/ui-egui/src/tests/open_lightroom.rs index d97f9d0e8..684883aa0 100644 --- a/crates/ui-egui/src/tests/open_lightroom.rs +++ b/crates/ui-egui/src/tests/open_lightroom.rs @@ -22,8 +22,16 @@ fn names(dir: &std::path::Path) -> Vec { v } -#[test] -fn open_library_on_a_lightroom_catalog_offers_its_import() { +struct Fx { + dir: std::path::PathBuf, + lib: std::path::PathBuf, + lr: std::path::PathBuf, + lrcat: std::path::PathBuf, + before: Vec, + photos: usize, +} + +fn setup() -> (Headless, Fx) { let dir = std::env::temp_dir().join(format!("lc-ui-openlrcat-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let lib = dir.join("LightCraft Library"); @@ -52,7 +60,12 @@ fn open_library_on_a_lightroom_catalog_offers_its_import() { }; let mut h = Headless::new(LightcraftApp::new(session, services), [1200.0, 800.0], 1.0); h.step(); + (h, Fx { dir, lib, lr, lrcat, before, photos }) +} +fn offer_and_cancel(h: &mut Headless, fx: &Fx) { + let Fx { lib, lr, lrcat, before, photos, .. } = fx; + let (photos, lib, lr, lrcat, before) = (*photos, lib.clone(), lr.clone(), lrcat.clone(), before.clone()); // File ▸ Open Library…, the Lightroom folder chosen in the folder dialog let r = h.app.run("app.openLibrary", json!({})).unwrap(); assert_eq!(r["opened"], false, "{r}"); @@ -64,7 +77,7 @@ fn open_library_on_a_lightroom_catalog_offers_its_import() { "{:?}", h.app.ui.dialog ); - assert!(has(&h, "button:dialogOk") && has(&h, "button:dialogCancel")); + assert!(has(h, "button:dialogOk") && has(h, "button:dialogCancel")); // the library open before is still open, and nothing was made in Lightroom's folder assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.clone()), Some(lib.clone())); assert_eq!(h.app.session.catalog.len(), photos); @@ -79,7 +92,10 @@ fn open_library_on_a_lightroom_catalog_offers_its_import() { h.step(); assert!(h.app.ui.dialog.is_none()); assert!(h.app.lightroom.is_none() && h.app.lightroom_last.is_none()); +} +fn reoffer_import_and_merge(h: &mut Headless, fx: &Fx) { + let (lib, lr, lrcat, before) = (fx.lib.clone(), fx.lr.clone(), fx.lrcat.clone(), fx.before.clone()); // a previews folder given by path (control channel, agents): the same offer let r = h.app.run("app.openLibrary", json!({"path": lr.join("Lightroom Catalog Previews.lrdata").to_string_lossy()})).unwrap(); assert_eq!(r["lightroomCatalog"], json!(lrcat.to_string_lossy()), "{r}"); @@ -108,6 +124,13 @@ fn open_library_on_a_lightroom_catalog_offers_its_import() { assert_eq!(last["path"], json!(lrcat), "{last}"); assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.clone()), Some(lib.clone())); assert_eq!(names(&lr), before, "the catalog is only read"); +} + +#[test] +fn open_library_on_a_lightroom_catalog_offers_its_import() { + let (mut h, fx) = setup(); + offer_and_cancel(&mut h, &fx); + reoffer_import_and_merge(&mut h, &fx); drop(h); - let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&fx.dir); } diff --git a/crates/ui-egui/src/tests/preview_limit.rs b/crates/ui-egui/src/tests/preview_limit.rs index 07a30c8a5..3687199c9 100644 --- a/crates/ui-egui/src/tests/preview_limit.rs +++ b/crates/ui-egui/src/tests/preview_limit.rs @@ -840,6 +840,72 @@ mod in_the_loupe { } } + fn drag_first_window(h: &mut Headless, work: &Rc>, id: lightcraft_catalog::PhotoId) -> RenderJob { + let first = next_region_job(h, work); + let view = h.app.region_view.expect("Detail asked for a window"); + assert_eq!(view.photo, id); + assert_eq!(view.full, (WIDTH as usize, HEIGHT as usize), "at 400 % the window's frame is the photo's own pixels"); + assert!(view.window.w >= 1300 && view.window.h >= 700, "the window covers the canvas: {:?}", view.window); + assert!(view.window.x + view.window.w <= view.full.0 && view.window.y + view.window.h <= view.full.1, "inside the frame: {:?}", view.window); + assert_eq!(first.request.window, Some(view.window), "the job is the window on screen"); + assert_eq!((first.request.max_w, first.request.max_h), view.full, "the job renders the frame the view uses"); + assert_eq!(first.request.quality, lightcraft_pipeline::Quality::Draft, "a drag drafts the window"); + assert_eq!(first.level, SourceLevel::Full); + assert!(matches!(&first.source, SourceRef::File { .. }), "the first window decodes the real file"); + assert_ne!(first.request_id, 0, "the renderer gave the request its own identity"); + first + } + + /// An edit during the first decode waits; the first result is accepted with its original. + fn drag_accepts_first_original( + h: &mut Headless, + work: &Rc>, + id: lightcraft_catalog::PhotoId, + first: RenderJob, + ) -> (u64, u64) { + // An edit changes the desired window key while the first original is still held. + // Detail must defer a second decode until this first result has populated the cache. + let (first_key, first_request) = (first.key, first.request_id); + let reply = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T); + assert_eq!(reply["ok"], true); + h.step(); + assert!(work.borrow().jobs.is_empty(), "a changed window waits for the first original"); + assert_eq!(h.app.renderer.wanted(Slot::Region), Some(first_key)); + assert_eq!(h.app.session.memory_report().full_source.bytes, 0); + + deliver(work, Slot::Region, first.run()); + h.step(); + assert!(h.app.region_view.is_some()); + assert_eq!(h.app.renderer.textures.get(&Slot::Region).map(|t| t.key), Some(first_key), "the first window arrived"); + assert!(h.app.session.memory_report().full_source.bytes >= WIDTH as usize * HEIGHT as usize * 3 * 4); + assert!(h.app.session.media.has_source(id, SourceLevel::Full), "the original was accepted before any supersession"); + (first_key, first_request) + } + + fn drag_later_windows_reuse_original(h: &mut Headless, work: &Rc>, first: (u64, u64), discarded: u64) { + let (first_key, first_request) = first; + let second = next_region_job(h, work); + let (second_key, second_request) = (second.key, second.request_id); + assert_ne!(second_key, first_key); + assert_ne!(second_request, first_request); + assert_eq!(h.app.renderer.wanted(Slot::Region), Some(second_key), "the newer window is pending"); + assert!(matches!(&second.source, SourceRef::Loaded(_)), "the newer window reuses the accepted original"); + + let reply = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 1.0}}), T); + assert_eq!(reply["ok"], true); + let third = next_region_job(h, work); + assert_ne!(third.key, second_key); + assert_eq!(h.app.renderer.wanted(Slot::Region), Some(third.key)); + + deliver(work, Slot::Region, second.run()); + h.step(); + assert_eq!(h.app.renderer.textures.get(&Slot::Region).map(|t| t.key), Some(second_key), "the superseded draft still reached the screen"); + assert_eq!(h.app.renderer.wanted(Slot::Region), Some(third.key), "the newest request is still pending"); + assert_eq!(h.app.renderer.discarded, discarded, "the superseded draft was accepted, not discarded"); + assert!(h.app.session.memory_report().full_source.bytes >= WIDTH as usize * HEIGHT as usize * 3 * 4); + assert!(matches!(&third.source, SourceRef::Loaded(_)), "the pending window still reuses the original"); + } + // Given a 400 % view of a real 24 MP photo, when the first window is decoded and accepted and // later drag ticks supersede each other, then the original stays in the cache and every later // window is cut from it. The first accepted result is the actual Detail wiring's job: the @@ -856,57 +922,9 @@ mod in_the_loupe { let discarded = h.app.renderer.discarded; h.app.ui.zoom = crate::state::Zoom::Percent(400.0); h.app.session.begin_interaction("Exposure").expect("a drag"); - - let first = next_region_job(&mut h, &work); - let (first_key, first_request) = (first.key, first.request_id); - let view = h.app.region_view.expect("Detail asked for a window"); - assert_eq!(view.photo, id); - assert_eq!(view.full, (WIDTH as usize, HEIGHT as usize), "at 400 % the window's frame is the photo's own pixels"); - assert!(view.window.w >= 1300 && view.window.h >= 700, "the window covers the canvas: {:?}", view.window); - assert!(view.window.x + view.window.w <= view.full.0 && view.window.y + view.window.h <= view.full.1, "inside the frame: {:?}", view.window); - assert_eq!(first.request.window, Some(view.window), "the job is the window on screen"); - assert_eq!((first.request.max_w, first.request.max_h), view.full, "the job renders the frame the view uses"); - assert_eq!(first.request.quality, lightcraft_pipeline::Quality::Draft, "a drag drafts the window"); - assert_eq!(first.level, SourceLevel::Full); - assert!(matches!(&first.source, SourceRef::File { .. }), "the first window decodes the real file"); - assert_ne!(first_request, 0, "the renderer gave the request its own identity"); - - // An edit changes the desired window key while the first original is still held. - // Detail must defer a second decode until this first result has populated the cache. - let reply = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T); - assert_eq!(reply["ok"], true); - h.step(); - assert!(work.borrow().jobs.is_empty(), "a changed window waits for the first original"); - assert_eq!(h.app.renderer.wanted(Slot::Region), Some(first_key)); - assert_eq!(h.app.session.memory_report().full_source.bytes, 0); - - deliver(&work, Slot::Region, first.run()); - h.step(); - assert!(h.app.region_view.is_some()); - assert_eq!(h.app.renderer.textures.get(&Slot::Region).map(|t| t.key), Some(first_key), "the first window arrived"); - assert!(h.app.session.memory_report().full_source.bytes >= WIDTH as usize * HEIGHT as usize * 3 * 4); - assert!(h.app.session.media.has_source(id, SourceLevel::Full), "the original was accepted before any supersession"); - - let second = next_region_job(&mut h, &work); - let (second_key, second_request) = (second.key, second.request_id); - assert_ne!(second_key, first_key); - assert_ne!(second_request, first_request); - assert_eq!(h.app.renderer.wanted(Slot::Region), Some(second_key), "the newer window is pending"); - assert!(matches!(&second.source, SourceRef::Loaded(_)), "the newer window reuses the accepted original"); - - let reply = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 1.0}}), T); - assert_eq!(reply["ok"], true); - let third = next_region_job(&mut h, &work); - assert_ne!(third.key, second_key); - assert_eq!(h.app.renderer.wanted(Slot::Region), Some(third.key)); - - deliver(&work, Slot::Region, second.run()); - h.step(); - assert_eq!(h.app.renderer.textures.get(&Slot::Region).map(|t| t.key), Some(second_key), "the superseded draft still reached the screen"); - assert_eq!(h.app.renderer.wanted(Slot::Region), Some(third.key), "the newest request is still pending"); - assert_eq!(h.app.renderer.discarded, discarded, "the superseded draft was accepted, not discarded"); - assert!(h.app.session.memory_report().full_source.bytes >= WIDTH as usize * HEIGHT as usize * 3 * 4); - assert!(matches!(&third.source, SourceRef::Loaded(_)), "the pending window still reuses the original"); + let first = drag_first_window(&mut h, &work, id); + let keys = drag_accepts_first_original(&mut h, &work, id, first); + drag_later_windows_reuse_original(&mut h, &work, keys, discarded); } // The collector's accept branch on its own: a superseded window result still carries the diff --git a/crates/ui-egui/src/tests/slideshow.rs b/crates/ui-egui/src/tests/slideshow.rs index 01a468ed8..26c72c2cd 100644 --- a/crates/ui-egui/src/tests/slideshow.rs +++ b/crates/ui-egui/src/tests/slideshow.rs @@ -871,20 +871,8 @@ fn settings_shows_the_titles_and_the_preview_shows_a_title_screen() { h.settle(SETTLE); } -/// Settings ▸ Slideshow ▸ Templates: built-ins listed, hover previews on the selected photo -/// without changing anything, a click applies, and the user's own templates are saved, updated, -/// renamed and deleted from the browser. -#[test] -fn template_browser_previews_on_hover_applies_and_manages_user_templates() { +fn tpl_hover_previews_without_changes(h: &mut Headless) -> lightcraft_engine::slide_template::SlideLook { use lightcraft_engine::slide_template::SlideLook; - let mut h = grid(); - let dir = std::env::temp_dir().join(format!("lc-ui-sstpl-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - h.app.session.slide_templates_dir = Some(dir.clone()); - let first = h.app.session.visible_cloned()[0]; - run(&mut h, "library.select", json!({"ids": [first.0]})); - run(&mut h, "view.slideshowOptions", json!({})); - assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); let rect = |h: &Headless, w: &str| h.app.widgets.iter().find(|(id, _)| id == w).map(|(_, r)| *r).unwrap(); for w in [ @@ -896,7 +884,7 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { "field:slideshowTemplateName", "button:slideshowTemplateSave", ] { - assert!(has(&h, w), "{w}"); + assert!(has(h, w), "{w}"); } // hover: the preview shows Gallery Wall (its light grey wall in the margin), nothing changes let before = SlideLook::of(&h.app.session); @@ -915,42 +903,55 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { assert!(h.step_until(SETTLE, grey), "the hovered template on the selected photo"); assert_eq!(SlideLook::of(&h.app.session), before, "hovering changes nothing"); assert!(h.app.session.slide_templates_previous.is_none()); + before +} + +fn tpl_click_applies_and_saves_own(h: &mut Headless, dir: &std::path::Path) { + let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); // a click applies it - reveal(&mut h, "template:builtin:gallery-wall"); + reveal(h, "template:builtin:gallery-wall"); let r = h.request("ui.clickWidget", json!({"id": "template:builtin:gallery-wall"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); assert!(h.app.session.slide_overlays.rating_stars.enabled && h.app.session.slide_layout.stroke_border); - assert!(has(&h, "template:previous"), "Previous settings appear"); + assert!(has(h, "template:previous"), "Previous settings appear"); // save as a template of our own - reveal(&mut h, "field:slideshowTemplateName"); + reveal(h, "field:slideshowTemplateName"); let r = h.request("ui.clickWidget", json!({"id": "field:slideshowTemplateName"}), T); assert_eq!(r["ok"], true, "{r}"); let r = h.request("ui.text", json!({"text": "Holiday / 2026"}), T); assert_eq!(r["ok"], true, "{r}"); - reveal(&mut h, "button:slideshowTemplateSave"); + reveal(h, "button:slideshowTemplateSave"); let r = h.request("ui.clickWidget", json!({"id": "button:slideshowTemplateSave"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); assert!(dir.join("Holiday _ 2026.json").exists(), "the separator never makes a folder"); h.step(); - assert!(has(&h, "template:user:Holiday _ 2026")); + assert!(has(h, "template:user:Holiday _ 2026")); +} + +fn tpl_update_from_menu(h: &mut Headless) { + let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); // update it from its menu with changed settings - run(&mut h, "slideshow.layout", json!({"backdropColor": "#123456"})); - reveal(&mut h, "template:user:Holiday _ 2026"); + run(h, "slideshow.layout", json!({"backdropColor": "#123456"})); + reveal(h, "template:user:Holiday _ 2026"); let r = h.request("ui.clickWidget", json!({"id": "template:user:Holiday _ 2026", "button": "right"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); for w in ["button:slideshowTemplateUpdate", "button:slideshowTemplateRename", "button:slideshowTemplateExport", "button:slideshowTemplateDelete"] { - assert!(has(&h, w), "{w}"); + assert!(has(h, w), "{w}"); } let r = h.request("ui.clickWidget", json!({"id": "button:slideshowTemplateUpdate"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); assert_eq!(h.app.session.slide_template("user:Holiday _ 2026").unwrap().look.layout.backdrop_color, [0x12, 0x34, 0x56]); +} + +fn tpl_rename_in_place(h: &mut Headless) { + let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); // rename it in place - reveal(&mut h, "template:user:Holiday _ 2026"); + reveal(h, "template:user:Holiday _ 2026"); let r = h.request("ui.clickWidget", json!({"id": "template:user:Holiday _ 2026", "button": "right"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); @@ -958,7 +959,7 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { assert_eq!(r["ok"], true, "{r}"); h.step(); h.step(); - assert!(has(&h, "field:slideshowTemplateRename")); + assert!(has(h, "field:slideshowTemplateRename")); let r = h.request("ui.key", json!({"key": "a", "cmd": true}), T); assert_eq!(r["ok"], true, "{r}"); let r = h.request("ui.text", json!({"text": "Summer"}), T); @@ -967,9 +968,13 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { assert_eq!(r["ok"], true, "{r}"); h.step(); h.step(); - assert!(has(&h, "template:user:Summer"), "{:?}", h.app.session.slide_templates().templates.iter().map(|t| t.id.clone()).collect::>()); + assert!(has(h, "template:user:Summer"), "{:?}", h.app.session.slide_templates().templates.iter().map(|t| t.id.clone()).collect::>()); +} + +fn tpl_delete_then_back_to_previous(h: &mut Headless, dir: &std::path::Path, before: &lightcraft_engine::slide_template::SlideLook) { + use lightcraft_engine::slide_template::SlideLook; // delete it (after confirming) - reveal(&mut h, "template:user:Summer"); + reveal(h, "template:user:Summer"); let r = h.request("ui.clickWidget", json!({"id": "template:user:Summer", "button": "right"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); @@ -983,12 +988,33 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { h.step(); assert!(!dir.join("Summer.json").exists()); // back to the settings before Gallery Wall - reveal(&mut h, "template:previous"); + reveal(h, "template:previous"); let r = h.request("ui.clickWidget", json!({"id": "template:previous"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); assert_eq!(SlideLook::of(&h.app.session).overlays, before.overlays); h.settle(SETTLE); +} + +/// Settings ▸ Slideshow ▸ Templates: built-ins listed, hover previews on the selected photo +/// without changing anything, a click applies, and the user's own templates are saved, updated, +/// renamed and deleted from the browser. +#[test] +fn template_browser_previews_on_hover_applies_and_manages_user_templates() { + let mut h = grid(); + let dir = std::env::temp_dir().join(format!("lc-ui-sstpl-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + h.app.session.slide_templates_dir = Some(dir.clone()); + let first = h.app.session.visible_cloned()[0]; + run(&mut h, "library.select", json!({"ids": [first.0]})); + run(&mut h, "view.slideshowOptions", json!({})); + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); + let before = tpl_hover_previews_without_changes(&mut h); + tpl_click_applies_and_saves_own(&mut h, &dir); + tpl_update_from_menu(&mut h); + tpl_rename_in_place(&mut h); + tpl_delete_then_back_to_previous(&mut h, &dir, &before); + h.settle(SETTLE); let _ = std::fs::remove_dir_all(dir); } From 307ecb981d05ee6a6412709fa0aeeb90589fc382 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 04:06:29 +0000 Subject: [PATCH 39/39] fix(ui-egui): keep the Export dialog's original spacing before the preset row --- crates/ui-egui/src/dialogs.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bca04fe0c..646288220 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1094,7 +1094,6 @@ fn export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut ui.add_space(4.0); export_naming_rows(ui, opts); export_destination_rows(app, ui, t, opts, dir); - ui.add_space(4.0); export_save_preset_row(app, ui, opts, *full_size, resize, *limit_kb, preset_name); }