# Conflicts:
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/panels/dialogs.rs
This commit is contained in:
Claude committed 2026-10-11 11:44:25 +00:00
commit 34b55101fe
7 files changed
+232 -12

No files matched your search

+4 -2
View File
@@ -266,7 +266,8 @@ pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
ui.label(RichText::new(crate::i18n::tr_format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().color(t.text_dim));
ui.separator();
egui::ScrollArea::both().id_salt("credits_scroll").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
let list_h = crate::panels::dialogs::list_height(ui, LIST_HEIGHT);
egui::ScrollArea::both().id_salt("credits_scroll").max_height(list_h).auto_shrink([false, true]).show(ui, |ui| {
if list.is_empty() {
ui.label(crate::i18n::tr("No contributor data was built into this copy."));
} else if v.table {
@@ -326,7 +327,8 @@ pub fn models_ui(ui: &mut egui::Ui) {
return;
}
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
egui::ScrollArea::both().id_salt("credits_models_scroll").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
let list_h = crate::panels::dialogs::list_height(ui, LIST_HEIGHT);
egui::ScrollArea::both().id_salt("credits_models_scroll").max_height(list_h).auto_shrink([false, true]).show(ui, |ui| {
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
ui.label(RichText::new(crate::i18n::tr(h)).strong());
+2
View File
@@ -59,6 +59,8 @@ mod tests_date_picker;
#[cfg(test)]
mod tests_dialogs;
#[cfg(test)]
mod tests_dialog_fit;
#[cfg(test)]
mod tests_filmstrip;
#[cfg(test)]
mod tests_grid;
+79 -5
View File
@@ -154,6 +154,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
// rather than growing the window when the options below the grid get taller (e.g. Copy).
let import = matches!(dlg, Dialog::Import { .. });
let window_id = window_id(&dlg);
// Every other dialog fits the window (#781): its options scroll when they don't, and the title
// bar and the button row stay outside the scroll area. The room they take is measured on the
// previous frame (a guess on the first one).
let chrome_id = window_id.with("chrome");
let chrome = ctx.data(|d| d.get_temp::<f32>(chrome_id)).unwrap_or(DIALOG_CHROME_GUESS);
let body_max = (screen.height() - 2.0 * DIALOG_SCREEN_MARGIN - chrome).max(DIALOG_MIN_BODY);
let mut body_h = None;
let shown = egui::Window::new(crate::i18n::tr(&title)).id(window_id)
.collapsible(false)
.resizable(import)
@@ -171,7 +178,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
})
.show(ctx, |ui| {
ui.spacing_mut().item_spacing.y = 8.0;
match &mut dlg {
body_h = dialog_body(ui, (!import).then_some(title.as_str()), body_max, |ui| match &mut dlg {
Dialog::AutoStack { gap } => {
ui.label(egui::RichText::new(crate::i18n::tr("Stack photos taken within this time of each other:")).color(t.text_label));
ui.add(
@@ -200,7 +207,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
ui.label(egui::RichText::new(crate::i18n::tr("Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.")).color(t.text_dim));
}
Dialog::WhatsNew => {
egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| {
egui::ScrollArea::vertical().max_height(list_height(ui, 460.0)).auto_shrink([false, true]).show(ui, |ui| {
for line in WHATS_NEW.lines() {
let l = line.trim_end();
if let Some(h) = l.strip_prefix("### ") {
@@ -260,7 +267,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
if !xmp.is_empty() {
groups.push(("XMP".into(), xmp));
}
egui::ScrollArea::vertical().max_height(440.0).auto_shrink([false, true]).show(ui, |ui| {
egui::ScrollArea::vertical().max_height(list_height(ui, 440.0)).auto_shrink([false, true]).show(ui, |ui| {
if groups.is_empty() {
ui.label(egui::RichText::new(crate::i18n::tr(rows["note"].as_str().unwrap_or("No metadata found"))).color(t.text_dim));
}
@@ -287,7 +294,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let now = (app.session.clock)();
let view = app.caches.rules_view(&app.session.catalog, rules, *id, folder, &now);
let env = crate::panels::rules_editor::Env { problems: view.problems.clone(), today: now, albums: view.albums.clone() };
egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, true]).show(ui, |ui| {
egui::ScrollArea::vertical().max_height(list_height(ui, 360.0)).auto_shrink([false, true]).show(ui, |ui| {
crate::panels::rules_editor::edit(ui, rules, "rules", 0, &[], &env);
});
let n = view.count;
@@ -994,7 +1001,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
}
Dialog::Shortcuts => crate::panels::keymap::body(app, ui, &t),
}
});
ui.add_space(4.0);
ui.horizontal(|ui| {
// a model file LightCraft cannot use has nothing to confirm, nor has a dialog that
@@ -1078,6 +1085,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
});
if let Some(w) = shown {
ctx.memory_mut(|m| m.set_modal_layer(w.response.layer_id));
if let Some(body_h) = body_h {
let measured = (w.response.rect.height() - body_h).max(0.0);
if (measured - chrome).abs() > 0.5 {
ctx.data_mut(|d| d.insert_temp(chrome_id, measured));
ctx.request_repaint();
}
}
ctx.move_to_top(w.response.layer_id);
crate::widgets::register(ctx, "dialog:window", w.response.rect);
}
@@ -1127,6 +1141,66 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
app.ui.dialog = if close { None } else { Some(dlg) };
}
/// Room kept free above and below a dialog (#781).
const DIALOG_SCREEN_MARGIN: f32 = 12.0;
/// A dialog's title bar, frame and button row before the first frame measures them.
const DIALOG_CHROME_GUESS: f32 = 100.0;
/// A dialog's options never get less room than this, however short the window.
const DIALOG_MIN_BODY: f32 = 80.0;
/// A list that scrolls on its own never gets shorter than this to make its dialog fit.
const DIALOG_MIN_LIST: f32 = 120.0;
fn list_shrink_id() -> egui::Id {
egui::Id::new("dialog-list-shrink")
}
fn list_applied_id() -> egui::Id {
egui::Id::new("dialog-list-applied")
}
/// The height for a list in a dialog that scrolls on its own (What's New, All Metadata, the
/// shortcuts, smart-album rules): `preferred`, less what the dialog needs to fit the window, so
/// on a short window the list gets shorter rather than scrolling inside a scrolling dialog.
pub fn list_height(ui: &egui::Ui, preferred: f32) -> f32 {
let shrink = ui.data(|d| d.get_temp::<f32>(list_shrink_id())).unwrap_or(0.0);
let height = (preferred - shrink).max(preferred.min(DIALOG_MIN_LIST));
ui.data_mut(|d| *d.get_temp_mut_or_default::<f32>(list_applied_id()) += preferred - height);
height
}
/// A dialog's options: in a vertical scroll area at most `max_height` tall when `scroll` names
/// the dialog (it shrinks to its content, so a dialog that fits looks as it would without it;
/// each dialog keeps a scroll position of its own), drawn directly otherwise. Returns the height
/// they took when scrolled.
fn dialog_body(ui: &mut egui::Ui, scroll: Option<&str>, max_height: f32, body: impl FnOnce(&mut egui::Ui)) -> Option<f32> {
let Some(name) = scroll else {
ui.data_mut(|d| d.remove_temp::<f32>(list_shrink_id()));
body(ui);
return None;
};
ui.data_mut(|d| d.insert_temp(list_applied_id(), 0.0_f32));
let top = ui.cursor().top();
// (an auto-sized window offers its content last frame's height; the minimum lets a dialog
// that grows take its full height at once, and the area still shrinks to fit its content)
let out = egui::ScrollArea::vertical()
.id_salt(("dialog-body", name))
.max_height(max_height)
.min_scrolled_height(max_height)
.auto_shrink([true, true])
.show(ui, body);
// what the lists that scroll on their own gave up this frame, and what they should next frame
let applied = ui.data(|d| d.get_temp::<f32>(list_applied_id())).unwrap_or(0.0);
let was = ui.data(|d| d.get_temp::<f32>(list_shrink_id())).unwrap_or(0.0);
let shrink = (out.content_size.y + applied - max_height).max(0.0);
if (shrink - was).abs() > 0.5 {
ui.data_mut(|d| d.insert_temp(list_shrink_id(), shrink));
ui.ctx().request_repaint();
}
// (the room the scroll area took: its `inner_rect` can be taller than what it allocated)
Some(ui.min_rect().bottom() - top)
}
/// Apply a dialog's action (also used by `ui.dialog.confirm`).
/// `90` → `1 min 30 s`.
pub fn fmt_gap(v: f64) -> String {
+3 -1
View File
@@ -58,7 +58,9 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
rows.sort_by_cached_key(|(b, _)| crate::i18n::tr(b.label).to_lowercase());
// a fixed height, so the dialog doesn't jump around while the search narrows the list
egui::ScrollArea::vertical().max_height(400.0).min_scrolled_height(400.0).auto_shrink([false, false]).id_salt("shortcuts-list").show(ui, |ui| {
// (shorter on a short window, so the dialog fits it)
let h = crate::panels::dialogs::list_height(ui, 400.0);
egui::ScrollArea::vertical().max_height(h).min_scrolled_height(h).auto_shrink([false, false]).id_salt("shortcuts-list").show(ui, |ui| {
egui::Grid::new("shortcuts").striped(true).num_columns(4).spacing([12.0, 4.0]).show(ui, |ui| {
for (b, sc) in &rows {
row(app, ui, t, b, sc.as_deref(), mac);
+133
View File
@@ -0,0 +1,133 @@
//! Issue #781: a dialog taller than the window keeps its title and buttons on screen; its options
//! scroll. On a 1366×768 screen the Export dialog ran off both ends and couldn't be confirmed.
use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
fn demo(size: [f32; 2]) -> Headless {
let services = Services { png: None, write_shared: Some(std::sync::Arc::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() };
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
let mut h = Headless::new(app, size, 1.0);
// let the first renders finish (a render still running at exit can crash in GPU teardown)
h.settle(T);
h
}
fn rect(h: &Headless, id: &str) -> egui::Rect {
h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}"))
}
fn open(h: &mut Headless, command: &str) {
let r = h.request("engine.execute", json!({"command": command, "params": {}}), T);
assert_eq!(r["ok"], true, "{command}: {r}");
assert!(h.app.ui.dialog.is_some(), "{command} opens a dialog");
h.settle(T);
}
/// The dialog lies inside the window, and so do its buttons (`button:dialogCancel` when it has one).
fn assert_fits(h: &Headless, what: &str) {
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size);
let dialog = rect(h, "dialog:window");
assert!(screen.contains_rect(dialog), "{what}: dialog {dialog:?} runs off the {screen:?} window");
let mut buttons = vec!["button:dialogOk"];
if h.app.widgets.iter().any(|(w, _)| w == "button:dialogCancel") {
buttons.push("button:dialogCancel");
}
for b in buttons {
let r = rect(h, b);
assert!(dialog.contains_rect(r), "{what}: {b} {r:?} is outside the dialog {dialog:?}");
}
}
/// The Export dialog in a window of `size`, set to write small JPEGs to a made-up folder.
fn export_dialog(size: [f32; 2]) -> Headless {
let mut h = demo(size);
open(&mut h, "dialog.export");
if let Some(crate::state::Dialog::Export { full_size, resize, dir, .. }) = &mut h.app.ui.dialog {
*full_size = false;
*resize = lightcraft_engine::export::Resize::long_edge(64);
*dir = "/lc-test-out".into();
}
h.settle(T);
h
}
// Scenario: on a 1366×768 screen (and a shorter window) the Export dialog's buttons are on screen and work
#[test]
fn export_dialog_fits_a_short_window_and_its_button_exports() {
for size in [[1366.0, 690.0], [1366.0, 520.0]] {
let mut h = export_dialog(size);
assert_fits(&h, &format!("Export at {size:?}"));
let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
h.step();
assert!(h.app.ui.dialog.is_none(), "Export closed the dialog at {size:?}");
assert!(h.app.export.is_some() || h.app.last_export_result.is_some(), "Export started at {size:?}");
assert!(h.step_until(Duration::from_secs(60), |h| h.app.export.is_none()));
}
}
// Scenario: Cancel is on screen too, and closes the dialog
#[test]
fn export_dialog_cancel_is_on_screen_in_a_short_window() {
let mut h = export_dialog([1366.0, 690.0]);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size);
assert!(screen.contains_rect(rect(&h, "button:dialogCancel")));
h.request("ui.clickWidget", json!({"id": "button:dialogCancel"}), T);
h.step();
h.step();
assert!(h.app.ui.dialog.is_none());
assert!(h.app.export.is_none() && h.app.last_export_result.is_none(), "nothing exported");
}
// Scenario: a window with room for it shows the Export dialog as before (no scrolling, same size)
#[test]
fn export_dialog_is_unchanged_in_a_tall_window() {
let h = export_dialog([1600.0, 1000.0]);
assert_fits(&h, "Export at 1600×1000");
let dialog = rect(&h, "dialog:window");
// (846 × 500.6 before #781: the whole dialog, nothing scrolled away)
assert!((dialog.height() - 846.0).abs() <= 1.0, "{dialog:?}");
assert!((dialog.width() - 500.6).abs() <= 1.0, "{dialog:?}");
}
// Scenario: other long dialogs fit a short window too, with their buttons on screen
#[test]
fn long_dialogs_fit_a_short_window() {
let mut h = demo([1000.0, 320.0]);
for command in [
"dialog.copySettings",
"dialog.pasteSettings",
"dialog.createPreset",
"dialog.contactSheet",
"app.settings",
"app.shortcuts",
"app.whatsNew",
"app.about",
"app.systemInfo",
"dialog.smartAlbum",
] {
open(&mut h, command);
assert_fits(&h, command);
h.app.ui.dialog = None;
h.settle(T);
}
}
// Scenario: a list that scrolls on its own (the shortcuts) gets shorter instead of scrolling inside a scrolling dialog
#[test]
fn a_dialog_list_shrinks_to_fit_a_short_window() {
let mut h = demo([1000.0, 320.0]);
open(&mut h, "app.shortcuts");
let shrink = h.view.ctx.data(|d| d.get_temp::<f32>(egui::Id::new("dialog-list-shrink"))).unwrap_or(0.0);
assert!(shrink > 0.0, "the list gave up room");
let dialog = rect(&h, "dialog:window");
assert!(dialog.height() <= 320.0, "{dialog:?}");
}
+5 -4
View File
@@ -49,13 +49,13 @@ cited here still exists. Formerly docs/parity.md.
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
| V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) |
| W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |
| X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) |
| X. Cross-cutting behaviours (BEHAV) | 19 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) |
| Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
| Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) |
| Lightroom Classic extras | 33 | 15 | 35 | 9 | — | 23/25 (92%) |
| **Total** | 397 | 49 | 73 | 36 | 192/200 (96%) | 143/155 (92%) |
| **Total** | 398 | 49 | 73 | 36 | 192/200 (96%) | 144/156 (92%) |
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.2%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 47.2% of 163.
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.2%** of 520 in-scope rows — P0 98.0% of 200 · P1 95.2% of 156 · P2 47.2% of 163.
<!-- /parity:summary -->
## Top gaps and shortcut notes
@@ -381,7 +381,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/panels/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 |
| 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/panels/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-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, WebP, AVIF, DNG (raw photos: lossless re-encode with the edits in the embedded XMP), Original (+ XMP sidecar). No JXL encoder; non-raw → DNG not supported |
| LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); W × H fits either orientation; ppi written to JFIF / pHYs / TIFF tags |
| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | |
@@ -459,6 +459,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 (import); 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/mod.rs` | |
| LR-BEHAV-DIALOGS | Dialogs fit the window | P1 | ✅ | `crates/ui-egui/src/panels/dialogs.rs` (`dialog_body`, `list_height`), `crates/ui-egui/src/tests_dialog_fit.rs` | 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; 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 |
| LR-BEHAV-SIDEBAR-COLLAPSE | Collapsible left-sidebar sections | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`sidebar_section_header`), `crates/ui-egui/src/state.rs` (`collapsed_sidebar`), `crates/ui-egui/src/tests_panels.rs` | click the Albums, Local, By Date or Keywords header (or drive `ui.clickWidget` `sidebarSection:<albums\|local\|byDate\|keywords>`) to fold or unfold the section; a chevron after the title shows the state; the choice is kept across restarts (`collapsedSidebar` in the UI state). UI-only (no command), like the By Date / Keywords disclosure triangles; photos dropped on a folded Albums section have no target |
| LR-BEHAV-PANEL-RESIZE | Resizable side panels | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`resizable_side`), `crates/ui-egui/src/state.rs` (`LEFT_WIDTH`, `RIGHT_WIDTH`), `crates/ui-egui/src/tests_panels.rs` | drag the left sidebar's right edge (200–480 pt) or the right panel's left edge (250–520 pt); the photo area keeps ≥ 360 pt; widths are kept across panels, views and restarts (`leftWidth` / `rightWidth` in the UI state); the Presets column stays fixed |
| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | |
+6
View File
@@ -10,6 +10,12 @@
## 2026-10-10
### Dialogs fit the window
- A dialog taller than the window no longer runs off it (issue #781): on a 1366×768 screen the Export dialog's title
and its Cancel / OK buttons were off screen, so it couldn't be confirmed. Now its options scroll and the title and
buttons stay visible. Long lists in dialogs (What's New, All Metadata, Keyboard Shortcuts) get shorter to fit.
Dialogs that fit look as before.
### Activity stack
- Long-running tasks show in one place, top left under the top bar, as in Lightroom Classic (issue #345): imports and
folder scans, exports (and the contact sheet PDF), Synchronize Folder, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan,