LR-BEHAV-DIALOGS: own verbs for Auto-Stack and Capture Time, test the button itself

Auto-Stack and Capture Time confirms reused the translation keys of a
noun ("Stack") and a field label ("Change"), so other languages showed
"Stapel" or "変更方法" on the button. They now have their own keys,
"Create Stacks" and "Change Time", in all 13 catalogs. Rename Keyword
says "Merge Keywords" when the new name already exists, as Rename
Person does. The Polish rename help says the file name changes.

The confirm label match is exhaustive now (no unreachable "OK" arm).
User guides, the Apple Photos note and the parity rows no longer tell
people to press OK in Export.

Tests read the text painted inside button:dialogOk, reach the plural
Export label, cover Auto-Stack and Slideshow Export plurals, Rename
Person, smart album Save, and check the German translation of the
verbs.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-28 08:53:55 -04:00
co-authored by Claude
parent 023ee0ef5b
commit e4f16fa90c
19 changed files with 151 additions and 48 deletions
+2
View File
@@ -501,6 +501,8 @@
"Cross Process": "Křížové vyvolání",
"Culling": "Třídění",
"Cull": "Roztřídit",
"Create Stacks": "Vytvořit sady",
"Change Time": "Změnit čas",
"Curve": "Křivka",
"Custom": "Vlastní",
"Custom template…": "Vlastní šablona…",
+2
View File
@@ -362,6 +362,8 @@
"Cross Process": "Crossentwicklung",
"Culling": "Fotoauswahl",
"Cull": "Auswählen",
"Create Stacks": "Stapel erstellen",
"Change Time": "Zeit ändern",
"Curve": "Kurve",
"Custom": "Benutzerdefiniert",
"Custom template…": "Benutzerdefinierte Vorlage…",
+2
View File
@@ -352,6 +352,8 @@
"Cross Process": "Proceso cruzado",
"Culling": "Selección",
"Cull": "Seleccionar",
"Create Stacks": "Crear pilas",
"Change Time": "Cambiar hora",
"Curve": "Curva",
"Custom": "Personalizado",
"Custom template…": "Plantilla personalizada…",
+2
View File
@@ -335,6 +335,8 @@
"Cross Process": "Traitement croisé",
"Culling": "Tri",
"Cull": "Trier",
"Create Stacks": "Créer les piles",
"Change Time": "Changer l'heure",
"Curve": "Courbe",
"Custom": "Personnalisé",
"Custom template…": "Modèle personnalisé…",
+2
View File
@@ -336,6 +336,8 @@
"Cross Process": "Processo incrociato",
"Culling": "Selezione",
"Cull": "Seleziona",
"Create Stacks": "Crea pile",
"Change Time": "Cambia ora",
"Curve": "Curva",
"Custom": "Personalizzato",
"Custom template…": "Modello personalizzato…",
+2
View File
@@ -326,6 +326,8 @@
"Cross Process": "クロスプロセス",
"Culling": "写真の選別",
"Cull": "選別",
"Create Stacks": "スタックを作成",
"Change Time": "時刻を変更",
"Curve": "カーブ",
"Custom": "カスタム",
"Custom template…": "カスタムテンプレート…",
+2
View File
@@ -509,6 +509,8 @@
"Cross Process": "ჯვარედინი დამუშავება",
"Culling": "გადარჩევა",
"Cull": "გადარჩევა",
"Create Stacks": "დასტების შექმნა",
"Change Time": "დროის შეცვლა",
"Culling.": "გადარჩევა.",
"Curve": "მრუდი",
"Custom": "მორგებული",
+3 -1
View File
@@ -324,6 +324,8 @@
"Cross Process": "Obróbka krzyżowa",
"Culling": "Selekcja zdjęć",
"Cull": "Selekcjonuj",
"Create Stacks": "Utwórz stosy",
"Change Time": "Zmień czas",
"Curve": "Krzywa",
"Custom": "Niestandardowe",
"Custom template…": "Szablon niestandardowy…",
@@ -1942,7 +1944,7 @@
"Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Pliki Sony ARW sprzed około 2017 r. (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II i pokrewne",
"Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Pomniejszone bezstratne pliki ARW z Sony ILCE-7M4 dekodują teraz próbki YCbCr do liniowej przestrzeni RGB,",
"TZ/ZS series, Leica D-Lux, V-Lux and C-Lux, and the GH6, GH7, G9 II, S5 II and S9 generation. They start from a": "serii TZ/ZS, Leica D-Lux, V-Lux i C-Lux oraz generacji GH6, GH7, G9 II, S5 II i S9. Wstępne kolory są dopasowywane do",
"The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Przycisk Tagi pokazuje symbole, których można użyć w szablonie, np. {date} lub {seq}. Każdy plik jest zmieniany na dysku razem ze swoim plikiem XMP; zajęta nazwa dostaje przyrostek -1, -2…",
"The Tags button lists placeholders for the template, such as {date} or {seq}. Each file is renamed on disk along with its XMP sidecar; a name that is taken gets -1, -2…": "Przycisk Tagi pokazuje symbole, których można użyć w szablonie, np. {date} lub {seq}. Nazwa każdego pliku na dysku jest zmieniana razem z jego plikiem XMP; zajęta nazwa dostaje przyrostek -1, -2…",
"The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Katalog ma wersję formatu (zgłoszenie #102). Otwarcie biblioteki ze starszego LightCraft aktualizuje ją; biblioteka",
"The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Port sterujący (`--control`) zamyka połączenie natychmiast po otrzymaniu danych innych niż żądanie JSON",
"The thumbnail cache only ever counts and deletes its own files (issue #98): a library opened on a folder that": "Pamięć podręczna miniaturek zlicza i usuwa wyłącznie własne pliki (zgłoszenie #98): otwarcie biblioteki w folderze, który",
+2
View File
@@ -343,6 +343,8 @@
"Cross Process": "Processamento cruzado",
"Culling": "Triagem",
"Cull": "Fazer triagem",
"Create Stacks": "Criar pilhas",
"Change Time": "Alterar hora",
"Curve": "Curva",
"Custom": "Personalizado",
"Custom template…": "Modelo personalizado…",
+2
View File
@@ -289,6 +289,8 @@
"Cross Process": "Кросс-процесс",
"Culling": "Отбор",
"Cull": "Отобрать",
"Create Stacks": "Создать стопки",
"Change Time": "Изменить время",
"Curve": "Кривая",
"Custom": "Свои",
"Custom…": "Свои…",
+2
View File
@@ -617,6 +617,8 @@
"Crop, Rotate, Geometry": "Кадрування, обертання, геометрія",
"Culling": "Відбір",
"Cull": "Відібрати",
"Create Stacks": "Створити стоси",
"Change Time": "Змінити час",
"Custom template…": "Власний шаблон…",
"Cycle Crop Overlay": "Перемкнути накладання кадрування",
"Cycle Crop Overlay Orientation": "Перемкнути орієнтацію накладання кадрування",
+2
View File
@@ -340,6 +340,8 @@
"Cross Process": "交叉冲洗",
"Culling": "挑片",
"Cull": "筛选",
"Create Stacks": "创建堆叠",
"Change Time": "更改时间",
"Curve": "曲线",
"Custom": "自定义",
"Custom template…": "自定义模板…",
+2
View File
@@ -341,6 +341,8 @@
"Cross Process": "交叉沖洗",
"Culling": "挑片",
"Cull": "開始挑片",
"Create Stacks": "建立堆疊",
"Change Time": "變更時間",
"Curve": "曲線",
"Custom": "自訂",
"Custom template…": "自訂範本…",
+16 -5
View File
@@ -355,9 +355,12 @@ fn ok_label(app: &LightcraftApp, dlg: &Dialog, informational: bool, sam: &SamSta
| Dialog::SmartRules { id: None, .. }
| Dialog::CreatePreset { .. } => "Create",
Dialog::SmartRules { .. } | Dialog::LabelNames { .. } => "Save",
Dialog::RenameKeyword { from, to } if app.session.catalog.has_keyword(to) && !lightcraft_catalog::keywords::same(from, to) => {
"Merge Keywords"
}
Dialog::RenameAlbum { .. } | Dialog::Rename { .. } | Dialog::RenameKeyword { .. } => "Rename",
Dialog::AutoStack { .. } => "Stack",
Dialog::CaptureTime { .. } => "Change",
Dialog::AutoStack { .. } => "Create Stacks",
Dialog::CaptureTime { .. } => "Change Time",
Dialog::Cull { .. } => "Cull",
Dialog::CopySettings { .. } => "Copy",
Dialog::PasteSettings { .. } => "Paste",
@@ -376,9 +379,17 @@ fn ok_label(app: &LightcraftApp, dlg: &Dialog, informational: bool, sam: &SamSta
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",
Dialog::RenamePerson { .. } => "Rename",
_ if informational => "Close",
// only a one-field prompt opened with a command that has no verb here
_ => "OK",
// the dialogs that only show something, or a model file that can't be used
Dialog::About
| Dialog::Shortcuts
| Dialog::Settings { .. }
| Dialog::SystemInfo { .. }
| Dialog::AllMetadata { .. }
| Dialog::WhatsNew
| Dialog::ManageEffects { .. }
| Dialog::ViewOptions
| Dialog::LightroomResult { .. }
| Dialog::FaceModel { .. } => "Close",
};
label.to_string()
}
+102 -36
View File
@@ -1,5 +1,6 @@
//! A dialog's confirm button names what it does ("Rename", "Create", "Stack"…), not "OK": the
//! button is the last thing read before a change is made.
//! A dialog's confirm button names what it does ("Rename", "Create", "Create Stacks"…), not "OK":
//! the button is the last thing read before a change is made. Every check reads the text painted
//! inside the `button:dialogOk` widget itself, not just any text in the window.
use crate::headless::Headless;
use crate::state::Dialog;
@@ -16,34 +17,57 @@ fn demo() -> Headless {
h
}
fn show(h: &mut Headless, dialog: Dialog) -> Vec<String> {
fn show(h: &mut Headless, dialog: Dialog) {
h.app.ui.dialog = Some(dialog);
h.step();
h.step();
h.painted_text()
}
fn dialog(value: serde_json::Value) -> Dialog {
serde_json::from_value(value).unwrap_or_else(|e| panic!("bad dialog json: {e}"))
}
/// Dialogs whose confirm may stay "OK" because nothing more specific is true of them: none today.
/// (A one-field prompt opened with a command the app doesn't know has no verb to name.)
const KEEPS_OK: &[&str] = &[];
/// The text painted inside the confirm button.
fn confirm_label(h: &Headless) -> String {
let rect = h.app.widgets.iter().find(|(w, _)| w == "button:dialogOk").map(|(_, r)| *r).unwrap_or_else(|| panic!("no confirm button"));
h.view
.painted_shapes()
.into_iter()
.filter_map(|s| match s {
egui::epaint::Shape::Text(t) if rect.contains(t.pos + t.galley.rect.center().to_vec2()) => Some(t.galley.job.text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("")
}
fn select(h: &mut Headless, n: usize) {
let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(n).collect();
h.app.session.selection.ids = ids;
}
fn text_with(h: &Headless, start: &str) -> String {
h.painted_text().into_iter().find(|t| t.starts_with(start)).unwrap_or_else(|| panic!("no text starting {start:?}"))
}
#[test]
fn confirm_buttons_name_their_action() {
let mut h = demo();
h.app
.run("photo.setMeta", json!({"ids": [h.app.session.catalog.photos().next().map(|p| p.id.0).unwrap_or(0)], "addKeywords": ["taken"]}))
.unwrap();
let cases: Vec<(&str, serde_json::Value, &str)> = vec![
("new album", json!({"kind": "newAlbum", "name": "", "folder": false}), "Create"),
("new folder", json!({"kind": "newAlbum", "name": "", "folder": true}), "Create"),
("rename album", json!({"kind": "renameAlbum", "id": 1, "name": "A"}), "Rename"),
("new smart album", json!({"kind": "newSmartAlbum", "name": ""}), "Create"),
("auto-stack", json!({"kind": "autoStack", "gap": 60.0}), "Stack"),
("capture time", json!({"kind": "captureTime", "mode": "set", "time": "", "days": 0, "hours": 0, "minutes": 0, "zone": 0.0}), "Change"),
("auto-stack", json!({"kind": "autoStack", "gap": 60.0}), "Create Stacks"),
("capture time", json!({"kind": "captureTime", "mode": "set", "time": "", "days": 0, "hours": 0, "minutes": 0, "zone": 0.0}), "Change Time"),
("label names", json!({"kind": "labelNames", "names": [], "save_as": ""}), "Save"),
("rename photos", json!({"kind": "rename", "template": "{name}", "start": 1}), "Rename"),
("rename keyword", json!({"kind": "renameKeyword", "from": "a", "to": "b"}), "Rename"),
("rename keyword onto an existing one", json!({"kind": "renameKeyword", "from": "a", "to": "taken"}), "Merge Keywords"),
("rename person", json!({"kind": "renamePerson", "from": "Ann", "to": "Anna", "merge": false}), "Rename"),
("merge keywords", json!({"kind": "mergeKeywords", "from": ["a"], "into": "b"}), "Merge Keywords"),
("assisted culling", json!({"kind": "cull", "reject_below": 0.0, "pick_best": true, "burst_gap": 10.0, "similarity": 0.93}), "Cull"),
("new preset", json!({"kind": "createPreset", "name": "", "group": ""}), "Create"),
@@ -51,9 +75,14 @@ fn confirm_buttons_name_their_action() {
("paste settings", json!({"kind": "pasteSettings", "groups": []}), "Paste"),
];
for (what, value, want) in cases {
let text = show(&mut h, dialog(value));
assert!(text.iter().any(|t| t == want), "{what}: the confirm button reads {want:?}: {text:?}");
assert!(KEEPS_OK.contains(&what) || !text.iter().any(|t| t == "OK"), "{what}: the confirm button still reads OK: {text:?}");
show(&mut h, dialog(value));
assert_eq!(confirm_label(&h), want, "{what}");
}
// a new smart album's rules are created; an existing one's are saved
for (id, want) in [(None, "Create"), (Some(1), "Save")] {
let d = Dialog::SmartRules { id, name: "A".into(), rules: Default::default(), parent: None };
show(&mut h, d);
assert_eq!(confirm_label(&h), want, "smart album rules {id:?}");
}
}
@@ -85,39 +114,76 @@ fn one_field_prompts_name_what_they_do() {
params: json!({}),
key: "name".into(),
};
let text = show(&mut h, prompt);
assert!(text.iter().any(|t| t == want), "{command}: the confirm button reads {want:?}: {text:?}");
assert!(!text.iter().any(|t| t == "OK"), "{command}: no OK: {text:?}");
show(&mut h, prompt);
assert_eq!(confirm_label(&h), want, "{command}");
}
}
#[test]
fn export_confirm_says_how_many_photos() {
let mut h = demo();
h.app.run("dialog.export", json!({})).unwrap();
h.step();
h.step();
let one = h.painted_text();
let n = h.app.session.selection.ids.len().max(1);
let want = if n == 1 { "Export 1 Photo".to_string() } else { format!("Export {n} Photos") };
assert!(one.contains(&want), "reads {want:?}: {one:?}");
assert!(!one.iter().any(|t| t == "OK"));
for (n, want) in [(1, "Export 1 Photo"), (3, "Export 3 Photos")] {
select(&mut h, n);
h.app.run("dialog.export", json!({})).unwrap();
h.step();
h.step();
assert_eq!(confirm_label(&h), want);
}
}
#[test]
fn the_confirm_is_the_verb_in_another_language() {
struct Restore;
impl Drop for Restore {
fn drop(&mut self) {
crate::i18n::set_language(crate::i18n::Locale::En);
}
}
let _restore = Restore;
let mut h = demo();
h.app.ui.language = crate::i18n::Locale::De;
show(&mut h, dialog(json!({"kind": "autoStack", "gap": 60.0})));
assert_eq!(confirm_label(&h), "Stapel erstellen");
show(&mut h, dialog(json!({"kind": "captureTime", "mode": "set", "time": "", "days": 0, "hours": 0, "minutes": 0, "zone": 0.0})));
assert_eq!(confirm_label(&h), "Zeit ändern");
show(&mut h, dialog(json!({"kind": "renameAlbum", "id": 1, "name": "A"})));
assert_eq!(confirm_label(&h), "Umbenennen");
}
/// "N noun(s)" in `text`: the number before `noun` and whether `noun` is written in the plural.
fn count_before(text: &str, noun: &str) -> (u64, bool) {
let at = text.find(noun).unwrap_or_else(|| panic!("{text:?} has no {noun:?}"));
let n = text[..at].trim_end().rsplit(' ').next().and_then(|w| w.parse().ok()).unwrap_or_else(|| panic!("no number before {noun:?} in {text:?}"));
(n, text[at + noun.len()..].starts_with('s'))
}
#[test]
fn counts_agree_with_their_nouns() {
let mut h = demo();
let ids: Vec<u64> = h.app.session.catalog.photos().map(|p| p.id.0).take(2).collect();
let rows = |h: &mut Headless, n: usize| {
h.app.session.selection.ids = ids.iter().take(n).map(|i| lightcraft_catalog::PhotoId(*i)).collect();
let text = show(
h,
dialog(json!({"kind": "contactSheet", "options": serde_json::to_value(lightcraft_engine::contact_sheet::Options::default()).unwrap()})),
);
text.into_iter().find(|t| t.contains("selected photo") || t.contains("1 photo")).unwrap_or_default()
};
let one = rows(&mut h, 1);
assert!(one.starts_with("1 selected photo ·") && one.ends_with("1 page"), "{one:?}");
let two = rows(&mut h, 2);
assert!(two.starts_with("2 selected photos ·") && two.ends_with("1 page"), "{two:?}");
// contact sheet: one photo, then two
for n in [1usize, 2] {
select(&mut h, n);
let options = serde_json::to_value(lightcraft_engine::contact_sheet::Options::default()).unwrap();
show(&mut h, dialog(json!({"kind": "contactSheet", "options": options})));
let text = text_with(&h, &format!("{n} selected photo"));
assert_eq!(text, if n == 1 { "1 selected photo · 1 page" } else { "2 selected photos · 1 page" });
}
// auto-stack: a tiny gap and a huge one give different counts; each agrees with its noun
for gap in [1.0, 86400.0] {
show(&mut h, dialog(json!({"kind": "autoStack", "gap": gap})));
let text = text_with(&h, "Creates ");
let (stacks, plural) = count_before(&text, " stack");
assert_eq!(plural, stacks != 1, "{text:?}");
}
// slideshow export: photos and slides
show(
&mut h,
dialog(
json!({"kind": "slideshowExport", "settings": serde_json::to_value(lightcraft_engine::slideshow_export::ExportSettings::default()).unwrap()}),
),
);
let text = h.painted_text().into_iter().find(|t| t.contains("in view")).unwrap_or_default();
let (photos, p1) = count_before(&text, " photo");
let (slides, p2) = count_before(&text, " slide");
assert_eq!((p1, p2), (photos != 1, slides != 1), "{text:?}");
}
+1 -1
View File
@@ -130,7 +130,7 @@ new empty library first: hold ⌥ while opening Photos ▸ Create New).
2. Open it from the Finder, or with `open target/macos-package/LightCraft.app --args --demo` (not by running
the binary in a terminal, which makes the terminal the app macOS asks about).
3. Select three photos, choose File ▸ Export…, set a folder (e.g. `~/Pictures/LightCraft Exports`), tick **Add
to Apple Photos**, type the album `LightCraft test "1"` and press OK.
to Apple Photos**, type the album `LightCraft test "1"` and press Export.
4. macOS asks whether **LightCraft** may control Photos, showing the usage text from `Info.plist`: allow it.
Expected: the toast reads "Exported 3 of 3 photos · 3 added to Apple Photos album “LightCraft test "1"”",
and a top-level album of that name holds the three images.
+2 -2
View File
@@ -389,7 +389,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) |
| LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / Export stay visible (LR-BEHAV-DIALOGS, issue #781) |
| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests/export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP; a raw without colour matrices of its own, as a NEF, gets the colour it opens with as `ColorMatrix1` / `ForwardMatrix1` / `AsShotNeutral`, also in Convert / Copy as DNG, so other raw converters open it, issue #1745), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build. Opaque 8-bit PNG/TIFF/WebP exports omit the redundant alpha channel; real transparency is retained (#1670). |
| LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `fit`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/dialogs.rs` | full size = the cropped native size (no longer upscaled); both of Lightroom's boxes: Dimensions (W × H fits either orientation) and Width & Height (`fit: widthHeight`, each edge bound to its own number, #1620); ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) |
| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`), `crates/codecs/src/jpeg_par.rs` | 4:4:4 chroma (4:2:0 when a file-size limit is set); Huffman tables made for each image from its own symbol counts (T.81 Annex K.2) instead of the example tables: the same pixels, 12–14 % smaller files at quality 80–100 on a 24 MP photo-like image, still interleaved baseline (issue #1373); ordinary and HDR gain map JPEGs search quality up to the requested maximum to fit `limitKb`, including gain map and metadata, or return an error. Tested by `size_limit_respected` and `hdr_jpeg_size_limit_respected`. |
@@ -468,7 +468,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-BEHAV-RENDER-FIDELITY | Rendering matches Lightroom | P1 | 🟡 | `crates/pipeline/src/lib.rs`, `crates/pipeline/src/tone.rs` | every slider exists and works, but the character of the result (tone curve shape, highlight recovery, texture / clarity / dehaze, noise reduction, sharpening, default look) is tuned by eye; there is no systematic side-by-side comparison against Lightroom on the same CC0 raws. Needs a fidelity suite: Lightroom reference renders kept only in the local `plan/` (never committed), compared per slider and preset with a perceptual metric. Tuning that changes how existing settings render ships as a new process version (LRC-DEV-PROCESS, `docs/process-versions.md`) |
| LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`, `album_drag_over`, `top_level_drop_target`, `drag_auto_scroll`) | files → app (a drop-target card while they hover, then the import review); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar) |
| LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels.rs` | |
| LR-BEHAV-DIALOGS | Dialogs fit the window | P1 | ✅ | `crates/ui-egui/src/dialogs.rs` (`dialog_body`, `list_height`), `crates/ui-egui/src/tests/dialog_fit.rs`, `crates/ui-egui/src/tests/dialog_copy.rs`, `crates/ui-egui/src/tests/dialogs.rs` (`a_click_outside_closes_the_dialog_when_settings_allow_it`) | a dialog taller than the window (Export on a 1366×768 screen, issue #781) never runs off it: its options scroll while the title bar and the Cancel / OK row stay on screen (the confirm button names its action: Create, Rename, Stack, Export 3 Photos…, with "OK" kept only for a one-field prompt opened with a command that has no verb, `crates/ui-egui/src/tests/dialog_copy.rs`); lists that scroll on their own (What's New, All Metadata, shortcuts, smart-album rules, credits) get shorter first; dialogs that fit look as before; Import keeps its own resizable, scrolling window; Settings → Dialogs → Close a dialog by clicking outside it (off by default, so a stray click never throws away a dialog's choices): a click pressed and released on the dimmed backdrop closes the dialog like Esc and doesn't reach the panels below, while a drag that ends outside (a slider pulled past the edge) or a click that closes a dropdown keeps it; the import review never closes this way (`clicked_backdrop`) |
| LR-BEHAV-DIALOGS | Dialogs fit the window | P1 | ✅ | `crates/ui-egui/src/dialogs.rs` (`dialog_body`, `list_height`), `crates/ui-egui/src/tests/dialog_fit.rs`, `crates/ui-egui/src/tests/dialog_copy.rs`, `crates/ui-egui/src/tests/dialogs.rs` (`a_click_outside_closes_the_dialog_when_settings_allow_it`) | a dialog taller than the window (Export on a 1366×768 screen, issue #781) never runs off it: its options scroll while the title bar and the Cancel / confirm row stay on screen (the confirm button names its action: Create, Rename, Create Stacks, Export 3 Photos…, with "OK" kept only for a one-field prompt opened with a command that has no verb, `crates/ui-egui/src/tests/dialog_copy.rs`); lists that scroll on their own (What's New, All Metadata, shortcuts, smart-album rules, credits) get shorter first; dialogs that fit look as before; Import keeps its own resizable, scrolling window; Settings → Dialogs → Close a dialog by clicking outside it (off by default, so a stray click never throws away a dialog's choices): a click pressed and released on the dimmed backdrop closes the dialog like Esc and doesn't reach the panels below, while a drag that ends outside (a slider pulled past the edge) or a click that closes a dropdown keeps it; the import review never closes this way (`clicked_backdrop`) |
| LR-BEHAV-SIDEBAR-COLLAPSE | Collapsible left-sidebar sections | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`section`, `section_header`), `crates/ui-egui/src/sidebar.rs` (`SidebarLayout`), `crates/ui-egui/src/tests/panels.rs` | click the Albums, Local, By Date, Folders or Keywords header (or drive `ui.clickWidget` `sidebarSection:<albums\|local\|byDate\|folders\|keywords>`) to fold or unfold the section; a chevron before the title shows the state; the choice is kept across restarts (`sidebar` in the UI state; the `collapsedSidebar` list of older settings files is still read). the click itself is UI-only, like the By Date / Keywords disclosure triangles, and `cmd:view.sidebarSection` `{section, collapsed}` folds and unfolds for agents; photos dropped on a folded Albums section have no target |
| LR-BEHAV-SIDEBAR-SECTIONS | Left-sidebar sections share one frame and one arrangement | P2 | ✅ | `crates/ui-egui/src/sidebar.rs` (`SidebarSection`, `SidebarLayout`), `crates/ui-egui/src/panels/left.rs` (`section`, `section_header`, `tree_toggle`), `crates/ui-egui/src/tests/panels.rs` | Albums, Local, By Date, Folders and Keywords are drawn by one `section` frame (the same gap above, header, fold and accessibility name) from one arrangement, `sidebar` in the UI state: a list of `{id, hidden, collapsed}` top to bottom, also settable with `ui.set`. Whatever is read, every section is in it exactly once (unknown and repeated entries are dropped, the rest follow in the usual order), so a damaged or newer settings file never loses the other settings. Rows that open (album folders, Local folders, dates, library folders, keywords) share `tree_toggle`: the same triangle, the same remembered open state, announced as Expand / Collapse. A section with nothing to list (Folders before anything is imported, Keywords before any is added) stays, open, with a one-line note in place of rows (`sidebarEmpty:<id>`) instead of disappearing. `ui.set` reads `sidebar` strictly (`SidebarLayout::parse`): an unknown or repeated section, or a flag that is not true / false, is refused and changes nothing; sections left out follow in the usual order, shown and open; the older `collapsedSidebar` list still folds and unfolds. A section may have one button at the end of its header, declared in one place (`header_button`: an icon, a tooltip, and either an action or a menu): the frame draws it at the visible edge in the header and its pinned copy, keeps its click from folding the section and leaves it room beside a folded section's count. Albums has + (`icon:albumNew`: Create Album / Smart Album / Folder…), Keywords has + (`icon:keywordNew`: Create Keyword Tag, for a keyword inside the default parent, not inside what the Keyword List has picked; once made, Keywords is shown and opened down to it). My Photos (All Photos, Recently Added, Picks, Missing Photos, Recently Deleted) is not a section: it stays on top |
| LR-BEHAV-SIDEBAR-HIDE | Show and hide left-sidebar sections | P2 | ✅ | `cmd:view.sidebarSection`, `cmd:view.sidebarReset`, `crates/ui-egui/src/sidebar.rs`, `crates/ui-egui/src/panels/left.rs` (`sections_menu`), `crates/ui-egui/src/menus/menubar.rs`, `crates/ui-egui/src/tests/panels.rs` | View ▸ Sidebar Sections lists Albums, Local, By Date, Folders and Keywords in the sidebar's order, each checked while shown; unchecking one takes its header and rows off the sidebar, checking it puts it back where and how it was (still folded if it was). The same list opens with a right-click on any section header (with Hide This Section first; widgets `sidebarMenu:hide`, `sidebarMenu:section:<id>`, `sidebarMenu:reset`) and on the My Photos title (`sidebarTitle`), which is always there, so hidden sections can always come back. Reset Sidebar Sections (enabled once something changed) shows and opens every section in the usual order. `view.sidebarSection` `{section, show?, collapsed?}` flips shown / hidden when neither flag is given and refuses an unknown section or a flag that is not true / false. Hiding the section of what the grid shows (an album, a keyword filter) leaves the grid as it is; an album made while Albums is hidden or folded brings Albums back, open. Kept with the UI state (`sidebar`). My Photos itself can't be hidden |
+2 -2
View File
@@ -5,7 +5,7 @@ order: 40
verified: 0.7.1
---
Select the photos, then choose **File** > **Export…**. Pick a format, a size and a folder, then click **OK**. Your originals are not changed.
Select the photos, then choose **File** > **Export…**. Pick a format, a size and a folder, then click **Export** (the button also says how many photos, such as **Export 3 Photos**). Your originals are not changed.
## Steps
@@ -20,7 +20,7 @@ Select the photos, then choose **File** > **Export…**. Pick a format, a size a
9. Set **File name** to a template such as `{name}-{seq}`. Click **Tags** to see the tags you can insert.
10. Set **If file exists** to **Add number**, **Overwrite** or **Skip**.
11. On macOS, tick **Add to Apple Photos** to add the exported files to Photos as well. Type an **Album** name to put them in that album. The first time, macOS asks whether LightCraft may control Photos.
12. Click **OK**. The export shows its progress in the activity stack at the top left.
12. Click **Export** (such as **Export 3 Photos**). The export shows its progress in the activity stack at the top left.
If you did not choose a folder, the window stays open and shows an error, so your choices are not lost.
+1 -1
View File
@@ -159,7 +159,7 @@
### Dialog buttons say what they do
- The confirm button in dialogs now names its action instead of "OK": Create for a new album, smart album, folder or
preset, Rename for photos, albums, keywords, people and presets, Stack for Auto-Stack, Change for Capture Time,
preset, Rename for photos, albums, keywords, people and presets, Create Stacks for Auto-Stack, Change Time for Capture Time,
Copy and Paste for settings, and Export 12 Photos in the Export dialog. Rename Photos now explains what its Tags
button lists and what happens on disk, instead of pointing at the button. Contact Sheet, Auto-Stack and
Slideshow Export no longer say "1 photos" or "1 stacks".