File presets in folders that open and close, as collections do

The presets menu and sheet listed every preset under flat section
headings, seventy rows to scroll past. They now list folders, closed
until opened, with how many presets each holds; opening one shows what
is inside it, folders and presets indented beneath.

A category is spelled in the name: "Portraits/Warm skin" is Warm skin
in a Portraits folder under Yours. The file format does not change, so
an older build lists the whole path as the name; renaming a preset is
how it moves, and saving or renaming into a folder opens the way to it.
A Lightroom import names what it reads after the folders below the one
chosen, and a "/" in a displayed name becomes "∕" so it files nothing.
The shipped film sections become Film › Colour, Cinema and Black and
white.

The tree is built and flattened in Rust (PresetTree), each row carrying
its depth, and which folders are open is remembered for the session.

A PopupWindow keeps the size it was shown at, so a folder opened in the
menu pushed its contents under "Save or manage…"; the menu is shown
again after each toggle to take its new height. That is a function on
the rail because Slint 1.17 generates Rust that does not compile for a
popup's close() reached from inside the popup. The sheet's list takes a
preferred height of up to 400px, since a Flickable reports next to
nothing and an opened folder showed three rows.

The manual describes the folders and naming. Its pictures show the menu
with Film › Colour open, and a black-and-white stock applied from Film
› Black and white; the scenes aim popup rows from the rail's entry, and
pick the menu's "Film" over the develop column's film chooser.
This commit is contained in:
2026-09-27 18:37:56 -04:00
parent 89b464db0c
commit 31bcc3a462
14 changed files with 680 additions and 174 deletions
+6 -4
View File
@@ -49,7 +49,9 @@ pub struct Section {
/// A stable identifier, for a frontend that remembers which sections a
/// photographer folded away. Never shown.
pub id: &'static str,
/// What the section is called on screen.
/// What the section is called on screen, as a category path: `/`
/// separates the levels, so `Film/Colour` is a folder inside `Film`. The
/// same spelling a photographer's own preset names use for theirs.
pub title: &'static str,
/// The presets in it, every one reaching only what it names.
pub presets: PresetLibrary,
@@ -65,17 +67,17 @@ const SECTIONS: &[(&str, &str, &str)] = &[
("skies", "Skies", include_str!("../presets/skies.drpl")),
(
"colour_film",
"Colour film",
"Film/Colour",
include_str!("../presets/colour_film.drpl"),
),
(
"cinema_film",
"Cinema film",
"Film/Cinema",
include_str!("../presets/cinema_film.drpl"),
),
(
"bw_film",
"Black and white film",
"Film/Black and white",
include_str!("../presets/bw_film.drpl"),
),
];
+59 -6
View File
@@ -573,6 +573,32 @@ mod tests {
assert_eq!(report.failed, 0);
}
#[test]
fn a_subfolder_becomes_the_category_of_what_it_holds() {
// The folder picked is the root and names nothing; each folder under
// it is a level of category, as Lightroom's groups were.
let dir = tempdir("categories");
std::fs::write(dir.join("Golden Hour.xmp"), ELEMENT_FORM).unwrap();
let nested = dir.join("Film").join("Colour");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join("Golden Hour.xmp"), ELEMENT_FORM).unwrap();
let names: Vec<_> = read_path(&dir).presets.into_iter().map(|p| p.0).collect();
assert_eq!(names, ["Film/Colour/Golden Hour", "Golden Hour"]);
}
#[test]
fn a_slash_in_a_displayed_name_is_not_a_category() {
let dir = tempdir("slash");
std::fs::write(
dir.join("p.xmp"),
ATTRIBUTE_FORM.replace("Warm Portrait", "Warm / Cool"),
)
.unwrap();
let report = read_path(&dir);
assert_eq!(report.presets[0].0, "Warm \u{2215} Cool");
}
#[test]
fn a_preset_without_a_name_is_called_after_its_file() {
// Lightroom writes the name it displays, which is not always the file
@@ -661,21 +687,37 @@ pub struct Report {
/// A folder because that is the shape a photographer's presets are in — an
/// exported Lightroom preset folder, nested one level per group — and asking
/// them to import ninety files one at a time would be asking them not to
/// bother. Nested folders are walked, which is what makes the group structure
/// available to whatever wants it later.
/// bother. Nested folders are walked, and each one below `path` becomes a
/// category: a preset in `Portraits/` is named `Portraits/Warm skin`, which is
/// how the preset menu files it (see `PresetLibrary`'s note on categories).
///
/// The *name* comes from `crs:Name` where the file carries one and from the
/// file stem where it does not. Lightroom writes the name it displays, which
/// is not always the file name, and the displayed name is the one the
/// photographer will look for.
/// photographer will look for. A `/` inside that name would read as a
/// category it never had, so it becomes `∕`, which looks the same and
/// separates nothing.
pub fn read_path(path: &std::path::Path) -> Report {
let mut report = Report::default();
read_into(path, &mut report);
read_into(path, "", &mut report);
report.presets.sort_by(|a, b| a.0.cmp(&b.0));
report
}
fn read_into(path: &std::path::Path, report: &mut Report) {
/// The category a folder below the import root files its presets under.
fn category_of(parent: &str, folder: &std::path::Path) -> String {
let Some(name) = folder.file_name() else {
return parent.to_string();
};
let name = name.to_string_lossy().replace('/', "\u{2215}");
if parent.is_empty() {
name
} else {
format!("{parent}/{name}")
}
}
fn read_into(path: &std::path::Path, category: &str, report: &mut Report) {
if path.is_dir() {
let Ok(entries) = std::fs::read_dir(path) else {
log::warn!("preset import: cannot read {}", path.display());
@@ -686,7 +728,12 @@ fn read_into(path: &std::path::Path, report: &mut Report) {
let mut paths: Vec<std::path::PathBuf> = entries.flatten().map(|e| e.path()).collect();
paths.sort();
for path in paths {
read_into(&path, report);
let category = if path.is_dir() {
category_of(category, &path)
} else {
category.to_string()
};
read_into(&path, &category, report);
}
return;
}
@@ -710,6 +757,12 @@ fn read_into(path: &std::path::Path, report: &mut Report) {
.unwrap_or_default()
});
report.unsupported.extend(import.skipped);
let name = name.replace('/', "\u{2215}");
let name = if category.is_empty() {
name
} else {
format!("{category}/{name}")
};
report.presets.push((name, import.preset));
}
Err(e) => {