Merge PR #2651: LR-FILT-FLAG: the filter bar's flag buttons combine, Picked + Unflagged hides only the rejects

Conflicts in docs/parity-checklist.md: kept both sides (union).
This commit is contained in:
aiongg
2026-10-31 02:07:00 +00:00
12 changed files with 193 additions and 12 deletions
+26
View File
@@ -36,6 +36,10 @@ pub struct Filter {
/// Any of these labels (the filter bar's multi-select); empty = no constraint.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<ColorLabel>,
/// Any of these flags (the filter bar's multi-select: Picked and Unflagged hide only the
/// rejects); empty = no constraint.
#[serde(skip_serializing_if = "Vec::is_empty", deserialize_with = "null_as_empty")]
pub flags: Vec<Flag>,
pub kind: Option<MediaKind>,
pub edited: Option<bool>,
pub album: Option<AlbumId>,
@@ -72,6 +76,11 @@ pub struct Filter {
pub rule_set: Option<crate::RuleSet>,
}
/// A list that may be given as `null`, read as empty: agents clear `flags` the way they clear `flag`.
fn null_as_empty<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result<Vec<T>, D::Error> {
Ok(Option::<Vec<T>>::deserialize(d)?.unwrap_or_default())
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SortKey {
@@ -266,6 +275,17 @@ impl Filter {
self.describe_in(Some(cat))
}
/// The flags that `flag` and `flags` let through together (they AND), in the filter bar's
/// order; `None` when neither is set, empty when they exclude each other. The chip and the
/// filter bar's buttons show this, so they match what is filtered.
pub fn flags_shown(&self) -> Option<Vec<Flag>> {
if self.flag.is_none() && self.flags.is_empty() {
return None;
}
let passes = |f: &Flag| self.flag.is_none_or(|x| x == *f) && (self.flags.is_empty() || self.flags.contains(f));
Some([Flag::Pick, Flag::Reject, Flag::None].into_iter().filter(passes).collect())
}
fn describe_in(&self, cat: Option<&crate::Catalog>) -> String {
let mut v: Vec<String> = Vec::new();
if self.rating > 0 {
@@ -279,6 +299,9 @@ impl Filter {
if let Some(f) = self.flag {
v.push(format!("flag {}", format!("{f:?}").to_lowercase()));
}
if !self.flags.is_empty() {
v.push(format!("flag {}", self.flags.iter().map(|f| format!("{f:?}").to_lowercase()).collect::<Vec<_>>().join(" or ")));
}
if let Some(l) = self.label {
v.push(format!("label {}", format!("{l:?}").to_lowercase()));
}
@@ -411,6 +434,9 @@ impl Filter {
if !self.labels.is_empty() && !p.label.is_some_and(|l| self.labels.contains(&l)) {
return false;
}
if !self.flags.is_empty() && !self.flags.contains(&p.flag) {
return false;
}
if self.only_none {
return false;
}
+25
View File
@@ -125,6 +125,31 @@ fn filter_search_sort() {
/// A search word with a colon whose left part is not a known field ("10:30", "Re:") is plain
/// text, not a fielded token that matches nothing.
/// `flags` (#2588) matches any of its flags; a filter saved before it, with the single `flag`, reads
/// and filters as it did, and a filter without `flags` is written without the key.
#[test]
fn filter_flags_any_of_and_the_old_single_flag() {
let mut c = Catalog::new();
let a = photo(&mut c, "a.jpg", "2026-01-01T10:00:00");
let b = photo(&mut c, "b.jpg", "2026-01-02T10:00:00");
let d = photo(&mut c, "d.jpg", "2026-01-03T10:00:00");
c.apply(Op::SetFlag { id: a, flag: Flag::Pick }).unwrap();
c.apply(Op::SetFlag { id: d, flag: Flag::Reject }).unwrap();
let f = Filter { flags: vec![Flag::Pick, Flag::None], ..Default::default() };
assert_eq!(c.query(&f, &Sort::default()), vec![b, a]);
let f = Filter { flags: vec![Flag::Reject, Flag::None], ..Default::default() };
assert_eq!(c.query(&f, &Sort::default()), vec![d, b]);
let saved: Filter = serde_json::from_value(serde_json::json!({"text": "", "rating": 0, "flag": "reject", "label": null})).unwrap();
assert!(saved.flags.is_empty());
assert_eq!(c.query(&saved, &Sort::default()), vec![d]);
let written = serde_json::to_value(&saved).unwrap();
assert_eq!(written.get("flag"), Some(&serde_json::json!("reject")));
assert!(written.get("flags").is_none(), "{written}");
let round: Filter =
serde_json::from_value(serde_json::to_value(Filter { flags: vec![Flag::Pick, Flag::None], ..Default::default() }).unwrap()).unwrap();
assert_eq!(round.flags, vec![Flag::Pick, Flag::None]);
}
#[test]
fn free_text_with_a_colon_searches_the_text_fields() {
let mut c = Catalog::new();
+2 -2
View File
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Filter",
[],
None,
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, libraryFolder?: a path from library.folders, keyword?, person?, camera?}",
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, flags?: [pick|reject|none] (any of them; [] = no constraint), label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, libraryFolder?: a path from library.folders, keyword?, person?, camera?}",
always,
|s, p| {
let mut v = serde_json::to_value(&s.filter).unwrap_or_default();
@@ -806,7 +806,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Smart Album…",
[],
None,
"{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album, ruleSet: {match: all|any|none, rules: [{field, op, value} | {group: ruleSet}]} — see album.ruleFields), parent?: folderId} — without `rules`, saves the current view (source + filter)",
"{name, rules?: partial Filter (rating, ratingOp, flag, flags, label, labels, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album, ruleSet: {match: all|any|none, rules: [{field, op, value} | {group: ruleSet}]} — see album.ruleFields), parent?: folderId} — without `rules`, saves the current view (source + filter)",
always,
|s, p| {
let name = str_param(p, "name").unwrap_or("Smart Album").trim().to_string();
+55
View File
@@ -357,6 +357,61 @@ fn filter_any_of_several_labels() {
assert!(s.visible_cloned().len() > want);
}
/// The filter bar's flag multi-select (#2588): Picked and Unflagged together hide only the rejects,
/// as Lightroom Classic's flag toggles do; one chip clears both, and the old single `flag` still filters.
#[test]
fn filter_any_of_several_flags() {
use lightcraft_catalog::Flag;
let mut s = crate::Session::with_demo();
let ids: Vec<u64> = s.catalog.photos().take(4).map(|p| p.id.0).collect();
for (id, f) in ids.iter().zip(["pick", "pick", "reject", "none"]) {
s.execute("photo.flag", &json!({"ids": [id], "flag": f})).unwrap();
}
let count = |s: &Session, want: &[Flag]| s.catalog.photos().filter(|p| p.in_library() && want.contains(&p.flag)).count();
let (picks, rejects, all) = (count(&s, &[Flag::Pick]), count(&s, &[Flag::Reject]), count(&s, &[Flag::Pick, Flag::Reject, Flag::None]));
assert!(picks >= 2 && rejects >= 1 && all > picks + rejects, "picks {picks}, rejects {rejects}, all {all}");
s.execute("library.filter", &json!({"flags": ["pick", "none"]})).unwrap();
let vis = s.visible_cloned();
assert_eq!(vis.len(), all - rejects);
assert!(vis.iter().all(|id| s.catalog.photo(*id).unwrap().flag != Flag::Reject));
assert!(s.filter.describe().contains("flag pick or none"), "{}", s.filter.describe());
let chips = crate::filter_chips(&s.filter, &s.catalog);
let chip = chips.iter().find(|c| c.label.starts_with("Flag")).expect("a flag chip");
assert_eq!(chip.label, "Flag: pick or none");
s.execute("library.filter", &chip.clear).unwrap();
assert_eq!(s.visible_cloned().len(), all);
// `null` clears the list, as it clears the single flag
s.execute("library.filter", &json!({"flags": ["reject"]})).unwrap();
assert_eq!(s.visible_cloned().len(), rejects);
s.execute("library.filter", &json!({"flags": null})).unwrap();
assert!(s.filter.flags.is_empty());
assert_eq!(s.visible_cloned().len(), all);
// the single flag of saved filters and agents filters as before
s.execute("library.filter", &json!({"flag": "pick"})).unwrap();
assert_eq!(s.visible_cloned().len(), picks);
}
/// `flag` and `flags` set together (only through `library.filter`) AND, and the chip says what
/// they let through, not their union.
#[test]
fn filter_flag_and_flags_together_chip() {
use lightcraft_catalog::Flag;
let mut s = crate::Session::with_demo();
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
for (id, f) in ids.iter().zip(["pick", "reject", "none"]) {
s.execute("photo.flag", &json!({"ids": [id], "flag": f})).unwrap();
}
let picks = s.catalog.photos().filter(|p| p.in_library() && p.flag == Flag::Pick).count();
let chip = |s: &Session| crate::filter_chips(&s.filter, &s.catalog).into_iter().find(|c| c.label.starts_with("Flag")).expect("a flag chip").label;
s.execute("library.filter", &json!({"flag": "pick", "flags": ["pick", "none"]})).unwrap();
assert_eq!(s.visible_cloned().len(), picks);
assert_eq!(chip(&s), "Flag: pick");
// nothing passes both: the chip names both constraints
s.execute("library.filter", &json!({"flag": "pick", "flags": ["reject", "none"]})).unwrap();
assert_eq!(s.visible_cloned().len(), 0);
assert_eq!(chip(&s), "Flag: pick and (reject or none)");
}
/// Quick Collection / target album: B toggles the selection in it (created on first use);
/// another album can be the target; clear empties it; all undoable.
#[test]
+12 -2
View File
@@ -158,8 +158,18 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec<FilterChip> {
};
add(format!("Rating {op} {}★", f.rating), json!({"rating": 0}));
}
if let Some(fl) = f.flag {
add(format!("Flag: {}", format!("{fl:?}").to_lowercase()), json!({"flag": Null}));
if let Some(shown) = f.flags_shown() {
let name = |fl: &lightcraft_catalog::Flag| format!("{fl:?}").to_lowercase();
let or = |fls: &[lightcraft_catalog::Flag]| fls.iter().map(name).collect::<Vec<_>>().join(" or ");
// empty: `flag` and `flags` exclude each other, so name both constraints
let names = if shown.is_empty() {
// parenthesised when plural, so "pick and reject or none" cannot read as "(pick and reject) or none"
let plural = if f.flags.len() > 1 { format!("({})", or(&f.flags)) } else { or(&f.flags) };
format!("{} and {plural}", f.flag.as_ref().map(name).unwrap_or_default())
} else {
or(&shown)
};
add(format!("Flag: {names}"), json!({"flag": Null, "flags": []}));
}
if let Some(shown) = f.labels_shown() {
let or = |ls: &[lightcraft_catalog::ColorLabel]| ls.iter().map(|l| cat.label_name(*l)).collect::<Vec<_>>().join(" or ");
+1
View File
@@ -29,6 +29,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
// The filter command merges a patch; these defaults are omitted by serde.
filter["only"] = json!([]);
filter["labels"] = json!([]);
filter["flags"] = json!([]);
filter["ruleSet"] = serde_json::Value::Null;
app.run("library.filter", filter)?;
app.run("library.select", json!({"ids": [first]}))?;
+37 -1
View File
@@ -1568,7 +1568,7 @@ fn filter_bar_filters_and_saves_a_smart_album() {
assert_eq!(r["ok"], true, "{w}: {r}");
}
let f = h.app.session.filter.clone();
assert_eq!((f.rating, f.flag, f.label), (3, Some(lightcraft_catalog::Flag::Pick), None), "a second click clears the label");
assert_eq!((f.rating, f.flag, &f.flags[..], f.label), (3, None, &[lightcraft_catalog::Flag::Pick][..], None), "a second click clears the label");
let n = h.app.session.visible_cloned().len();
assert!(n > 0 && n < all);
h.request("ui.clickWidget", json!({"id": "button:filterSave"}), t);
@@ -1583,6 +1583,42 @@ fn filter_bar_filters_and_saves_a_smart_album() {
h.settle(SETTLE);
}
/// The filter bar's flag buttons toggle on and off together, as Lightroom Classic's do (#2588):
/// Picked + Unflagged shows everything but the rejects, a second click turns one off, and a
/// filter saved with the old single flag shows its button lit and folds into the list.
#[test]
fn filter_bar_flags_combine() {
use lightcraft_catalog::Flag;
let mut h = demo([1400.0, 800.0]);
let t = Duration::from_secs(10);
let ids: Vec<u64> = h.app.session.catalog.photos().take(3).map(|p| p.id.0).collect();
for (id, f) in ids.iter().zip(["pick", "reject", "none"]) {
h.app.session.execute("photo.flag", &json!({"ids": [id], "flag": f})).unwrap();
}
let rejects = h.app.session.catalog.photos().filter(|p| p.in_library() && p.flag == Flag::Reject).count();
let all = h.app.session.visible_cloned().len();
h.request("engine.execute", json!({"command": "view.filterBar"}), t);
for w in ["filter:none", "filter:pick"] {
let r = h.request("ui.clickWidget", json!({"id": w}), t);
assert_eq!(r["ok"], true, "{w}: {r}");
}
assert_eq!(h.app.session.filter.flags, vec![Flag::Pick, Flag::None]);
assert_eq!(h.app.session.visible_cloned().len(), all - rejects);
h.request("ui.clickWidget", json!({"id": "filter:none"}), t);
assert_eq!(h.app.session.filter.flags, vec![Flag::Pick]);
// an old single-flag filter: Unflagged joins it instead of replacing it
h.app.session.execute("library.filter", &json!({"flag": "reject", "flags": []})).unwrap();
h.request("ui.clickWidget", json!({"id": "filter:none"}), t);
let f = h.app.session.filter.clone();
assert_eq!((f.flag, f.flags), (None, vec![Flag::Reject, Flag::None]));
// both set through the API: only Picked passes, so only Picked is lit and Unflagged joins it
h.app.session.execute("library.filter", &json!({"flag": "pick", "flags": ["pick", "none"]})).unwrap();
h.request("ui.clickWidget", json!({"id": "filter:none"}), t);
let f = h.app.session.filter.clone();
assert_eq!((f.flag, f.flags), (None, vec![Flag::Pick, Flag::None]));
h.settle(SETTLE);
}
/// The photo grid shows date headers (registered as `group:<date>` widgets); clicking one
/// selects that day's photos; month headers when zoomed out; none when grouping is off.
#[test]
+7
View File
@@ -603,6 +603,13 @@ fn german_switches_via_menu_and_control_and_persists() {
app.run("label.setNames", serde_json::json!({"names": {"red": "Color"}})).unwrap();
let label = filter_label(&filter, &app.session.catalog);
assert!(label.contains("Color") && !label.contains(&red) && label.contains(&blue), "{label}");
let filter = lightcraft_catalog::Filter { flags: vec![lightcraft_catalog::Flag::Pick, lightcraft_catalog::Flag::None], ..Default::default() };
let (flag, pick, reject, none) = (tr("Flag"), tr("pick"), tr("reject"), tr("none"));
assert_eq!(filter_label(&filter, &app.session.catalog), format!("{flag}: {pick} {or} {none}"));
let filter = lightcraft_catalog::Filter { flag: Some(lightcraft_catalog::Flag::Pick), ..filter };
assert_eq!(filter_label(&filter, &app.session.catalog), format!("{flag}: {pick}"));
let filter = lightcraft_catalog::Filter { flags: vec![lightcraft_catalog::Flag::Reject, lightcraft_catalog::Flag::None], ..filter };
assert_eq!(filter_label(&filter, &app.session.catalog), format!("{flag}: {pick} {and} ({reject} {or} {none})"));
set_language(Locale::En);
}
+1
View File
@@ -645,6 +645,7 @@ mod tests {
h.app.session.filter.text = "no-photo-has-this-name".into();
h.app.session.filter.only = vec![lightcraft_catalog::PhotoId(u64::MAX)];
h.app.session.filter.labels = vec![lightcraft_catalog::ColorLabel::Red];
h.app.session.filter.flags = vec![lightcraft_catalog::Flag::Reject];
h.app.session.filter.rule_set = Some(lightcraft_catalog::RuleSet {
mode: lightcraft_catalog::rules::Match::All,
rules: vec![lightcraft_catalog::rules::Rule::Field { field: "rating".into(), op: "is".into(), value: json!(5) }],
+13 -1
View File
@@ -46,7 +46,7 @@ fn label_names(filter: &lightcraft_catalog::Filter, catalog: &lightcraft_catalog
fn prefixed_value(prefix: &str, value: &str, filter: &lightcraft_catalog::Filter) -> String {
use crate::i18n::{date_group_label, tr};
let date = match prefix {
"Flag" | "Type" => return tr(value).to_string(),
"Type" => return tr(value).to_string(),
"Date" => &filter.date,
"Imported" => &filter.imported,
_ => return value.to_string(),
@@ -68,6 +68,18 @@ pub(crate) fn display_label(chip: &FilterChip, filter: &lightcraft_catalog::Filt
if chip.clear.get("label").is_some() {
return format!("{}: {}", tr("Label"), label_names(filter, catalog));
}
if chip.clear.get("flags").is_some() {
let shown = filter.flags_shown().unwrap_or_default();
let name = |flag: &lightcraft_catalog::Flag| tr(&format!("{flag:?}").to_lowercase()).to_string();
let or = |flags: &[lightcraft_catalog::Flag]| flags.iter().map(name).collect::<Vec<_>>().join(&format!(" {} ", tr("or")));
let names = if shown.is_empty() {
let plural = if filter.flags.len() > 1 { format!("({})", or(&filter.flags)) } else { or(&filter.flags) };
format!("{} {} {plural}", filter.flag.as_ref().map(name).unwrap_or_default(), tr("and"))
} else {
or(&shown)
};
return format!("{}: {names}", tr("Flag"));
}
if chip.clear.get("dateFrom").is_some() || chip.clear.get("dateTo").is_some() {
return match (&filter.date_from, &filter.date_to) {
(Some(from), Some(to)) => format!("{} {} – {}", tr("Captured"), date_group_label(from, false), date_group_label(to, false)),
+10 -3
View File
@@ -135,14 +135,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
ui.add_space(14.0);
// flags (one at a time; click again to clear)
// flags: several can be on at once (any of them matches), as in Lightroom Classic
caption(ui, "Flag");
let chosen: Vec<Flag> = f.flags_shown().unwrap_or_default();
for (id, icon, flag, tip) in [
("pick", Icon::FlagPick, Flag::Pick, "Picked"),
("reject", Icon::FlagReject, Flag::Reject, "Rejected"),
("none", Icon::Circle, Flag::None, "Unflagged"),
] {
let on = f.flag == Some(flag);
let on = chosen.contains(&flag);
let color = match flag {
Flag::Pick if on => t.pick,
Flag::Reject if on => t.reject,
@@ -151,7 +152,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let icon = if on && flag == Flag::Pick { Icon::FlagPickFilled } else { icon };
let resp = toggle(ui, id, on, tip, |p, r, c| paint(p, r, icon, if color == Color32::TRANSPARENT { c } else { color }));
if resp.clicked() {
filter(app, json!({"flag": if on { Value::Null } else { json!(id) }}));
// in the bar's order, so the chip reads the same whichever was clicked first
let names: Vec<String> = [Flag::Pick, Flag::Reject, Flag::None]
.into_iter()
.filter(|x| if *x == flag { !on } else { chosen.contains(x) })
.map(|x| format!("{x:?}").to_lowercase())
.collect();
filter(app, json!({"flag": Value::Null, "flags": names}));
}
}
ui.add_space(14.0);
+4 -3
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (LR-VIEW-PHOTOGRID, LR-VIEW-FILMSTRIP, LR-VIEW-COMPARE, LR-VIEW-SURVEY, LRC-LIB-REFVIEW, LRC-LIB-PEOPLE: the active photo's selection ring shows on light themes and in Compare, Survey and the face tiles, and needs a loaded thumbnail; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (LR-FILT-FLAG / LRC-LIB-FILTERBAR: the flag toggles combine, Picked + Unflagged, #2588; LR-VIEW-PHOTOGRID, LR-VIEW-FILMSTRIP, LR-VIEW-COMPARE, LR-VIEW-SURVEY, LRC-LIB-REFVIEW, LRC-LIB-PEOPLE: the active photo's selection ring shows on light themes and in Compare, Survey and the face tiles, and needs a loaded thumbnail; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
@@ -149,7 +149,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-FILT-SEARCH-META | Text search | P0 | ✅ | `cmd:library.filter` (`text`), `crates/ui-egui/src/panels/topbar.rs`, `crates/catalog/src/query.rs` | fielded tokens (`rating:3`, `iso:>800`, `rating:>=3` / `iso:<=400`, `camera:…`); plain words also match the people named on faces; double quotes keep several words in one token (`person:"Jane Doe"`, `"jane doe"`, issue #1449); no suggestions dropdown |
| LR-FILT-SEARCH-AI | Natural-language search | P2 | ⬜ | | |
| LR-FILT-RATING | Rating filter | P0 | ✅ | `cmd:library.filter` (`rating`, `ratingOp`), `crates/ui-egui/src/panels/filterbar.rs` | ≥ / = / ≤ stars in the filter bar (`cmd:view.filterBar`) |
| LR-FILT-FLAG | Flag filter | P0 | ✅ | `cmd:library.filter` (`flag`), `crates/ui-egui/src/panels/filterbar.rs` | picked / rejected / unflagged (one at a time) |
| LR-FILT-FLAG | Flag filter | P0 | ✅ | `cmd:library.filter` (`flag`, `flags`), `crates/ui-egui/src/panels/filterbar.rs` | picked / rejected / unflagged, toggled on and off like Classic's Attribute flag buttons: several at once show any of them (Picked + Unflagged hides only the rejects, issue #2588; `flags`, one chip); the single `flag` of saved filters, smart albums and agents still filters |
| LR-FILT-LABEL | Colour-label filter | P1 | ✅ | `cmd:library.filter` (`label`), `crates/ui-egui/src/panels/filterbar.rs` | one label at a time; no “no label” choice |
| LR-FILT-TYPE | Type / edited filter | P1 | ✅ | `cmd:library.filter` (`kind`, `merged`, `edited`), `crates/ui-egui/src/panels/filterbar.rs`, `crates/catalog/src/query.rs` (`merged_kind`) | photos / raw / videos / HDR / panoramas / HDR panoramas (merge results by name), edited / unedited; no depth kind |
| LR-FILT-KEYWORD | Keyword filter | P1 | ✅ | `cmd:library.filter` (`keyword`), `crates/ui-egui/src/panels/filterbar.rs` | keyword picker |
@@ -707,7 +707,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) |
| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/dialogs/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/widgets/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: no rating 0–5 is 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/widgets/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents |
| LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | |
| LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (an album the filter held is replaced by the album you show; always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor |
| LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`flags`, `labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag (several flags at once = any of them)/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (an album the filter held is replaced by the album you show; always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor |
| LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down |
| LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' |
| LRC-LIB-LABELS | Colour-label sets | P1 | ✅ | `cmd:label.sets`, `cmd:label.applySet`, `cmd:label.saveSet`, `cmd:label.deleteSet`, `cmd:label.setNames`, `crates/engine/src/cmd/manage.rs` | built-in Colors / Review sets + user sets (Photo ▸ Set Color Label, Edit Label Names… dialog); names written to and read from `xmp:Label` |
@@ -804,6 +804,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-24 | minor | KEYC-METADATA: ⌘K / Ctrl+K (`panel.addKeywords`, Add Keywords) shows the Keywords panel, opens Keywording and puts the cursor in the Add keyword box; status counts unchanged (KEYC-METADATA stays 🟡) |
| 2026-10-24 | patch | LR-LIB-RATING, LR-LIB-DELETE: a slider drag still open when a rating, flag or label takes the photo out of the view, or when the photo is deleted, ends as its own undo step before the selection moves on, as moving to the next photo does; LR-BEHAV-LOCALIZE lists all 18 languages; status unchanged |
| 2026-10-24 | patch | Integration batch 43: Integrated main 98702b5c, preserving the new fail-closed GPS metadata check for Original export. Watermark geometry/orientation and HDR JPEG size-limit fixes remain in their shared helpers; no feature-count changes. |
| 2026-10-23 | minor | LR-FILT-FLAG, LRC-LIB-FILTERBAR: the filter bar's flag toggles combine (any of them, `library.filter` `flags`), issue #2588; status and counts unchanged |
| 2026-10-23 | patch | Export integration: Integrated main 5ca49b3e, retaining Width & Height resizing, export format/bit-depth validation and batch panic cancellation alongside the existing watermark and HDR JPEG size-limit fixes. No feature-count changes. |
| 2026-10-23 | minor | LRC-LIB-CATALOG-SWITCH, LR-PREF-GENERAL: Settings ▸ General ▸ At launch (Open the last library / Ask me) with a launch window listing the recent libraries, Alt/Option at launch asks once, File ▸ Open Recent Library ▸ Remove from List and `app.recentLibraries {remove}` (issue #1290); photos dropped while no library is open wait for the one opened (presets are refused then); status and counts unchanged (✅) |
| 2026-10-23 | minor | LRC-LIB-CATALOG-IMPORT: Import Lightroom Catalog… (and Open Library's offer for a Lightroom catalog) asks whether the catalog merges into the open library or goes into a new library made next to it; Settings ▸ General or "Don't ask again" switches the question off; `file.importLightroom` takes `into` / `name` / `location` (#2672); status unchanged (🟡) |