diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index f6372f44d..37198f74c 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -128,28 +128,37 @@ fn select_by(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ // ---- view source / filter / sort - cmd!("library.source", "Show Source", [], None, "{kind: all|recentlyAdded|album|recentlyDeleted|picks, id?: albumId}", always, |s, p| { - let kind = str_param(p, "kind").unwrap_or("all"); - s.source = match kind { - "all" => LibrarySource::All, - "recentlyAdded" => LibrarySource::RecentlyAdded, - "recentlyDeleted" => LibrarySource::RecentlyDeleted, - "picks" => LibrarySource::Picks, - "album" => { - let a = album_param(p, "id", "library.source")?; - if s.catalog.album(a).is_none_or(|a| a.folder) { - return Err(bad("library.source", "no such album")); + cmd!( + "library.source", + "Show Source", + [], + None, + "{kind: all|recentlyAdded|album|recentlyDeleted|picks|missing, id?: albumId}", + always, + |s, p| { + let kind = str_param(p, "kind").unwrap_or("all"); + s.source = match kind { + "all" => LibrarySource::All, + "recentlyAdded" => LibrarySource::RecentlyAdded, + "recentlyDeleted" => LibrarySource::RecentlyDeleted, + "picks" => LibrarySource::Picks, + "missing" => LibrarySource::Missing, + "album" => { + let a = album_param(p, "id", "library.source")?; + if s.catalog.album(a).is_none_or(|a| a.folder) { + return Err(bad("library.source", "no such album")); + } + LibrarySource::Album(a) } - LibrarySource::Album(a) + other => return Err(bad("library.source", format!("unknown source `{other}`"))), + }; + let vis = s.visible_cloned(); + if s.selection.active.is_none_or(|a| !vis.contains(&a)) { + s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default(); } - other => return Err(bad("library.source", format!("unknown source `{other}`"))), - }; - let vis = s.visible_cloned(); - if s.selection.active.is_none_or(|a| !vis.contains(&a)) { - s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default(); + Ok(json!({"count": vis.len()})) } - Ok(json!({"count": vis.len()})) - }), + ), cmd!( "library.filter", "Filter", diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d74f9beb5..6fe797d11 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -15,7 +15,7 @@ mod library; mod manage; mod masks; mod merge; -mod missing; +pub mod missing; mod organize; mod prefs; mod preset_files; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0bec679d1..2ee8be40c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -382,6 +382,10 @@ impl Session { self.visible.reverse(); } } + if self.source == LibrarySource::Missing { + let missing: std::collections::HashSet = cmd::missing::missing(self).into_iter().map(|m| m.0).collect(); + self.visible.retain(|id| missing.contains(id)); + } if self.source != LibrarySource::RecentlyDeleted { self.visible = self.catalog.arrange_stacks(&self.visible); } diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index cb7c065e5..454c053d2 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -166,6 +166,9 @@ fn missing_files_are_found_and_relinked() { std::fs::rename(dir.join("old/b.png"), dir.join("moved/deeper/b.png")).unwrap(); let m = s.execute("library.missing", &serde_json::json!({})).unwrap(); assert_eq!(m.as_array().map(Vec::len), Some(2)); + s.execute("library.source", &serde_json::json!({"kind": "missing"})).unwrap(); + assert_eq!(s.visible_cloned().len(), 2, "the Missing Photos source lists them"); + s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap(); // one by hand let id = m[0]["id"].as_u64().unwrap(); let name = std::path::Path::new(m[0]["path"].as_str().unwrap()).file_name().unwrap().to_string_lossy().to_string(); diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 01c1b3851..ab4133af9 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -17,6 +17,8 @@ pub enum LibrarySource { Picks, /// A folder on disk ([`crate::Session::browse`]): its files, added to the library or not. Folder, + /// Photos whose original file can't be found (`library.missing`). + Missing, } /// The folder a [`LibrarySource::Folder`] view shows. @@ -40,7 +42,7 @@ impl LibrarySource { LibrarySource::RecentlyDeleted => f.deleted = true, LibrarySource::Picks => f.flag = Some(lightcraft_catalog::Flag::Pick), // the folder itself is filled in by the session (it holds the path) - LibrarySource::Folder => {} + LibrarySource::Folder | LibrarySource::Missing => {} } f } @@ -53,6 +55,7 @@ impl LibrarySource { LibrarySource::RecentlyDeleted => "Recently Deleted".into(), LibrarySource::Picks => "Picks".into(), LibrarySource::Folder => "Folder".into(), + LibrarySource::Missing => "Missing Photos".into(), } } } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index caec678ba..0bd24553c 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -728,6 +728,26 @@ mod tests { assert_eq!(exposure(&h), 1.0); } + /// ↑ / ↓ over a slider nudge it (one undo step each); ⇧Z picks and moves to the next photo. + #[test] + fn slider_nudge_and_pick_advance() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "edit"}), t); + h.settle(SETTLE); + let exposure = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure; + assert_eq!(h.request("ui.hoverWidget", json!({"id": "slider:light.exposure"}), t)["ok"], true); + h.request("ui.key", json!({"key": "Up"}), t); + h.request("ui.key", json!({"key": "Up", "shift": true}), t); + assert!((exposure(&h) - 0.30).abs() < 1e-9, "{}", exposure(&h)); + h.request("ui.key", json!({"key": "Down"}), t); + assert!((exposure(&h) - 0.25).abs() < 1e-9, "{}", exposure(&h)); + let first = h.app.session.active().unwrap(); + h.request("ui.key", json!({"key": "Z", "shift": true}), t); + assert_eq!(h.app.session.catalog.photo(first).unwrap().flag, lightcraft_catalog::Flag::Pick); + assert_ne!(h.app.session.active(), Some(first), "advanced"); + } + /// ⌘Q (File → Quit LightCraft) closes the window. #[test] fn cmd_q_quits() { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 2143902f9..405db1335 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -315,7 +315,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } // drawn and hit-tested above the loupe and its tools: clicks on it pan navigator(app, ui, canvas, img_rect, id); - resp.context_menu(|ui| super::grid::context_menu(app, ui, id)); + resp.context_menu(|ui| { + ui.menu_button("Zoom", |ui| { + for (label, cmd) in [("Fit", "view.zoomFit"), ("100%", "view.zoom100"), ("Zoom In", "view.zoomIn"), ("Zoom Out", "view.zoomOut")] { + if ui.button(label).clicked() { + let _ = app.run(cmd, json!({})); + } + } + }); + ui.separator(); + super::grid::context_menu(app, ui, id); + }); } /// The info overlay at the canvas' top left (`view.infoOverlay`): file name with the capture date diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index d1c285739..f79ff254d 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -74,6 +74,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { 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() + { + let _ = app.run("library.source", json!({"kind": "missing"})); + } ui.add_space(10.0); // Albums header let (ar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); @@ -129,6 +136,22 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } +/// How many library photos have no file (cached in egui memory, refreshed every 5 s). +fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize { + let id = egui::Id::new("missing-count"); + let now = ui.input(|i| i.time); + let rev = app.session.catalog.revision; + if let Some((n, at, r)) = ui.data(|d| d.get_temp::<(usize, f64, u64)>(id)) + && now - at < 5.0 + && r == rev + { + return n; + } + let n = lightcraft_engine::cmd::missing::missing(&app.session).len(); + ui.data_mut(|d| d.insert_temp(id, (n, now, rev))); + n +} + /// Folders on this computer to browse without adding (Lightroom's Local): Pictures, Desktop, /// Downloads, the home folder, the folder being browsed, and Browse Folder…. fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 2d49eaf8d..d92d7331c 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -15,6 +15,7 @@ pub const ALIASES: &[(&str, &str, &str)] = &[ ("Space", "view.zoomToggle", "{}"), ("Shift+M", "version.create", "{}"), ("Shift+X", "photo.flag", r#"{"flag": "reject", "advance": true}"#), + ("Shift+Z", "photo.flag", r#"{"flag": "pick", "advance": true}"#), ("Shift+U", "photo.flag", r#"{"flag": "none", "advance": true}"#), // Shift+[ / Shift+] arrive as { / } on most layouts ("Shift+{", "brush.featherLess", "{}"), diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 482b906fe..bfa0d6033 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -156,6 +156,23 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ if resp.drag_stopped() { out.drag_stopped = true; } + // ↑ / ↓ while the pointer rests on the row nudge the value (⇧: five times as much) + if enabled && out.value.is_none() && ui.rect_contains_pointer(row) { + 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) + }); + 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; + } + } + } } // paint let hovered = resp.hovered() || resp.dragged(); @@ -184,6 +201,14 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ out } +/// `value` moved by `steps` keyboard nudges: one nudge is about 1/200 of the range, a whole +/// number of the control's steps (exposure 0.05, most sliders 1, temperature 50 K). +pub fn nudged(spec: &ControlSpec, value: f64, steps: f64) -> f64 { + let step = spec.step.max(1e-9); + let unit = if spec.id == "wb.temp" { 50.0 } else { (((spec.max - spec.min) / 200.0 / step).round().max(1.0)) * step }; + (((value + unit * steps) / step).round() * step).clamp(spec.min, spec.max) +} + /// Collapsible section header ("› Light"). Returns the response (click toggles). pub fn section_header(ui: &mut Ui, id: &str, title: &str, open: bool, enabled: Option) -> (Response, Option) { let t = Tokens::get(ui.ctx()); @@ -365,3 +390,27 @@ pub fn dropdown(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Co paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 7.0, r.center().y + 1.0), vec2(12.0, 12.0)), Icon::ChevronDown, c); resp } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nudges_are_a_sensible_step() { + let spec = |id, min, max, step| ControlSpec { + id, + label: "", + section: lightcraft_develop::controls::Section::Light, + min, + max, + default: 0.0, + step, + decimals: 2, + track: Track::Plain, + }; + assert!((nudged(&spec("light.exposure", -5.0, 5.0, 0.01), 0.0, 1.0) - 0.05).abs() < 1e-9); + assert_eq!(nudged(&spec("light.contrast", -100.0, 100.0, 1.0), 10.0, -5.0), 5.0); + assert_eq!(nudged(&spec("wb.temp", 2000.0, 50000.0, 1.0), 6500.0, 1.0), 6550.0); + assert_eq!(nudged(&spec("light.contrast", -100.0, 100.0, 1.0), 99.0, 5.0), 100.0, "clamped"); + } +} diff --git a/docs/parity.md b/docs/parity.md index ab8383714..408baf52d 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -45,12 +45,12 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 12 | 1 | 4 | 1 | 8/8 (100%) | 4/5 (80%) | -| Y. Menus | 69 | 8 | 8 | 8 | 46/47 (98%) | 18/23 (78%) | -| Z. Keyboard shortcuts (desktop) | 63 | 12 | 5 | 1 | 49/52 (94%) | 12/23 (52%) | -| Lightroom Classic extras | 8 | 20 | 51 | 9 | — | 7/21 (33%) | -| **Total** | 310 | 62 | 128 | 37 | 193/198 (97%) | 98/144 (68%) | +| Y. Menus | 70 | 7 | 8 | 8 | 46/47 (98%) | 19/23 (83%) | +| Z. Keyboard shortcuts (desktop) | 65 | 10 | 5 | 1 | 49/52 (94%) | 14/23 (61%) | +| Lightroom Classic extras | 10 | 19 | 50 | 9 | — | 9/21 (43%) | +| **Total** | 315 | 58 | 127 | 37 | 193/198 (97%) | 103/144 (72%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **68.2%** of 500 in-scope rows — P0 98.7% of 198 · P1 81.6% of 144 · P2 17.7% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **68.8%** of 500 in-scope rows — P0 98.7% of 198 · P1 83.7% of 144 · P2 17.7% of 158. ## Top gaps @@ -582,7 +582,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-HELP-FEEDBACK | Send feedback | P2 | ⬜ | | | | MENU-HELP-SYSINFO | System info | P2 | ⬜ | `cmd:library.info` | library info only | | MENU-CTX-GRID | Photo context menu | P0 | ✅ | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete | -| MENU-CTX-DETAIL | Loupe context menu | P1 | 🟡 | `crates/ui-egui/src/panels/detail.rs` | same as grid; no zoom submenu | +| MENU-CTX-DETAIL | Loupe context menu | P1 | ✅ | `crates/ui-egui/src/panels/detail.rs` | Zoom submenu (fit, 100%, in, out), then the photo menu | | MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export` | add selected, export album (dialog / preset), move to a folder or the top level, rename, delete; smart albums: update rules | | MENU-CTX-MASK | Mask / component menu | P0 | 🟡 | `crates/ui-egui/src/panels/masking.rs` (`mask_menu`) | mask rows: duplicate (and invert), invert, show/hide, move, rename, delete; components: add/subtract only (no per-component menu) | | MENU-CTX-PRESET | Preset menu | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs` | favourite, update with current settings, rename, move to group, delete; group: export | @@ -655,7 +655,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-MASKING | Masking — M | P0 | ✅ | `cmd:panel.masking` | | | KEY-ERASE | Erase while held — ⌥ | P0 | ✅ | `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/panels/masking.rs` | ⌥ while painting flips Add ↔ Erase for that stroke; Add/Erase buttons too | | KEY-RATEADVANCE | Rate and advance — ⇧0…5 | P1 | ✅ | `crates/ui-egui/src/shortcuts.rs` → `cmd:photo.rate` (`advance`) | | -| KEY-FLAGADVANCE | Flag and advance — ⇧Z / ⇧X / ⇧U | P1 | 🟡 | `cmd:photo.flag` (`advance`) | ⇧X and ⇧U bound; pick-and-advance has no key (⇧P = presets) | +| KEY-FLAGADVANCE | Flag and advance — ⇧Z / ⇧X / ⇧U | P1 | ✅ | `cmd:photo.flag` (`advance`), `crates/ui-egui/src/shortcuts.rs` | ⇧Z pick, ⇧X reject, ⇧U unflag, each moving to the next photo | | KEY-NEXTPREV | Next / previous — → / ← | P0 | ✅ | `cmd:library.next`, `cmd:library.previous` | | | KEY-BA-CYCLE | Before/after — Y | P0 | ✅ | `cmd:view.beforeAfter` | toggles side by side (no cycling) | | KEY-BA-TOPBOTTOM | Before/after top/bottom — ⌥Y | P1 | ✅ | `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom` | ⌥Y, ⇧⌥Y | @@ -670,7 +670,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-CROP-RESET | Reset crop — ⌥⌘R | P0 | ✅ | `cmd:crop.reset` | | | KEY-STRAIGHTEN | Straighten while held — ⌘ drag | P1 | ⬜ | | | | KEY-SLIDER-RESET | Reset slider — double-click | P0 | ✅ | `crates/ui-egui/src/widgets.rs` | | -| KEY-SLIDER-NUDGE | Nudge slider — ↑/↓ | P1 | 🟡 | `cmd:develop.adjust` | command exists; keyboard nudging unverified | +| KEY-SLIDER-NUDGE | Nudge slider — ↑/↓ | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`nudged`), `cmd:develop.adjust` | ↑/↓ over any slider: ≈ 1/200 of its range (exposure 0.05, most sliders 1, temperature 50 K); ⇧ ×5; one undo step each | | KEY-SHORTCUTS | Shortcut list — ⌘/ | P1 | ✅ | `cmd:app.shortcuts` | | | KEY-HELP | Help — F1 | P2 | ✅ | `cmd:app.help` | | | KEY-VIDEO-PLAY | Play/pause video — Space | P1 | ⬜ | | | @@ -688,11 +688,11 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-IMPORT | Full import dialog | P1 | 🟡 | `cmd:library.import` (`mode: copy`) | copy/add + duplicate skip; no file renaming, apply-during-import, destination organising, import presets | | LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ⬜ | | | | LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | | | -| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.detail` | no compare, survey, people; no grid cell styles beyond filenames | +| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey` | grid, square grid, loupe, compare, survey; no people view; grid cell styles: filenames only | | LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; zoom always linked | | LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | | LRC-LIB-REFVIEW | Reference view | P2 | ⬜ | | | -| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | 🟡 | `cmd:library.source` | all, recently added, picks, recently deleted; no missing/problem sets | +| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | | LRC-LIB-FOLDERS | Disk folder tree | P1 | 🟡 | `cmd:library.browse`, `crates/ui-egui/src/panels/left.rs` (`local_section`) | standard places + the browsed folder + Browse Folder…, breadcrumb navigation upwards; no expandable folder tree, no folder rename/move | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | 🟡 | `cmd:album.create` | albums + folders; no smart / quick / target collections | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | 🟡 | `cmd:album.createSmart`, `crates/catalog/src/query.rs` | the filter fields + date range; no rule editor, any/none groups or operators beyond ≥/=/≤ | @@ -702,7 +702,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' | | LRC-LIB-LABELS | Colour-label sets | P1 | ⬜ | | | | LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter | P1 | 🟡 | `crates/catalog/src/keywords.rs`, `cmd:keyword.list` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); no keyword sets or painter | -| LRC-LIB-METADATA | Metadata panel & presets | P1 | 🟡 | `cmd:photo.setMeta`, `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile` | no metadata presets, capture-time edit, copyright status | +| LRC-LIB-METADATA | Metadata panel & presets | P1 | 🟡 | `cmd:photo.setMeta`, `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:dialog.captureTime` | IPTC core fields, accessibility text, place, capture-time edit; no metadata presets, copyright status | | LRC-LIB-QUICKDEV | Quick develop | P2 | ⬜ | | | | LRC-LIB-PEOPLE | People view | P2 | ⬜ | | | | LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | | @@ -714,11 +714,11 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-DEV-SNAPSHOTS | Named snapshots | P1 | ✅ | `cmd:version.create`, `cmd:version.restore` | = versions | | LRC-DEV-HISTORY | Full history panel | P1 | ✅ | `cmd:history.list`, `cmd:history.restore`, `cmd:history.clear` | row menu: copy step to before, create version from step, clear history | | LRC-DEV-SOFTPROOF | Soft proofing | P2 | ⬜ | | | -| LRC-DEV-AUTOSYNC | Sync / auto sync / paste previous | P1 | 🟡 | `cmd:develop.sync` | no auto sync or paste-from-previous | +| LRC-DEV-AUTOSYNC | Sync / auto sync / paste previous | P1 | 🟡 | `cmd:develop.sync`, `cmd:develop.pastePrevious` | sync and paste from previous; no auto sync | | LRC-DEV-MATCHEXP | Match total exposures | P2 | ⬜ | | | | LRC-DEV-CALIB | Calibration panel | P1 | ✅ | `ctl:calibration.*` | | | LRC-DEV-TAT | Targeted adjustment tools | P1 | ✅ | `cmd:develop.targeted` (`target`: curve / hue / sat / lum) | | -| LRC-DEV-DEFAULTS | Per-camera raw defaults | P1 | ⬜ | | | +| LRC-DEV-DEFAULTS | Per-camera raw defaults | P1 | ✅ | `cmd:library.preferences` (`camera`, `import.perCamera`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import: raw default and per-camera presets | | LRC-DEV-VIEWOPTIONS | Develop view options | P2 | ⬜ | | | | LRC-DEV-VIDEO | Video frame capture | P2 | ⬜ | | | | LRC-MAP-VIEW | Map view | P2 | ⬜ | | | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 2703532d7..a98c3c81b 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -9,8 +9,8 @@ mod ico; mod layers; mod parity; mod stats; -mod web; mod version; +mod web; use std::path::PathBuf; use std::process::{Command, ExitCode}; diff --git a/xtask/src/version.rs b/xtask/src/version.rs index 489b62678..8c4c1fc34 100644 --- a/xtask/src/version.rs +++ b/xtask/src/version.rs @@ -8,10 +8,7 @@ use std::path::Path; /// The `version = "…"` value inside `[workspace.package]`. pub fn read(manifest: &str) -> Result { let (_, line) = find_line(manifest)?; - let v = line - .split_once('=') - .map(|(_, v)| v.trim().trim_matches('"').to_string()) - .unwrap_or_default(); + let v = line.split_once('=').map(|(_, v)| v.trim().trim_matches('"').to_string()).unwrap_or_default(); if v.is_empty() { return Err("empty [workspace.package] version".into()); } @@ -48,20 +45,11 @@ pub fn validate(v: &str) -> Result<(), String> { None => (v, None), }; let nums: Vec<&str> = core.split('.').collect(); - if nums.len() != 3 - || nums.iter().any(|n| { - n.is_empty() - || !n.bytes().all(|b| b.is_ascii_digit()) - || (n.len() > 1 && n.starts_with('0')) - }) - { + if nums.len() != 3 || nums.iter().any(|n| n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) || (n.len() > 1 && n.starts_with('0'))) { return bad(); } if let Some(p) = pre - && (p.is_empty() - || p.split('.').any(|id| { - id.is_empty() || !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') - })) + && (p.is_empty() || p.split('.').any(|id| id.is_empty() || !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-'))) { return bad(); }