mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-19 02:10:25 -05:00
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:
1 parent
aeaf4154f7
commit
b2c8d9611f
13 files changed
+161
-51
No files matched your search
@@ -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",
|
||||
|
||||
@@ -15,7 +15,7 @@ mod library;
|
||||
mod manage;
|
||||
mod masks;
|
||||
mod merge;
|
||||
mod missing;
|
||||
pub mod missing;
|
||||
mod organize;
|
||||
mod prefs;
|
||||
mod preset_files;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", "{}"),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user