KEY-SLIDER-NUDGE + KEY-FLAGADVANCE: slider nudges, ⇧Z pick and advance, Missing Photos source

- ↑/↓ over any slider nudge it by ≈ 1/200 of its range (exposure 0.05, most sliders 1,
  temperature 50 K; ⇧ ×5), one undo step each; the key doesn't also move the selection.
- ⇧Z picks and moves to the next photo (with ⇧X / ⇧U).
- library.source {kind: missing}: photos whose file can't be found; a "Missing Photos" row
  appears in the left panel when there are any (checked every 5 s).
- Loupe context menu: Zoom submenu. Parity: stale rows fixed (compare/survey, per-camera
  defaults, paste from previous, capture-time edit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 16:27:35 -04:00
1 parent aeaf4154f7
commit b2c8d9611f
13 files changed
+161 -51

No files matched your search

+28 -19
View File
@@ -128,28 +128,37 @@ fn select_by(s: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
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",
+1 -1
View File
@@ -15,7 +15,7 @@ mod library;
mod manage;
mod masks;
mod merge;
mod missing;
pub mod missing;
mod organize;
mod prefs;
mod preset_files;
+4
View File
@@ -382,6 +382,10 @@ impl Session {
self.visible.reverse();
}
}
if self.source == LibrarySource::Missing {
let missing: std::collections::HashSet<PhotoId> = 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);
}
+3
View File
@@ -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();
+4 -1
View File
@@ -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(),
}
}
}
+20
View File
@@ -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() {
+11 -1
View File
@@ -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
+23
View File
@@ -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) {
+1
View File
@@ -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", "{}"),
+49
View File
@@ -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<bool>) -> (Response, Option<bool>) {
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");
}
}
+13 -13
View File
@@ -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.
<!-- /parity:summary -->
## 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 | ⬜ | | |
+1 -1
View File
@@ -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};
+3 -15
View File
@@ -8,10 +8,7 @@ use std::path::Path;
/// The `version = "…"` value inside `[workspace.package]`.
pub fn read(manifest: &str) -> Result<String, String> {
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();
}