Merge PR #1468: Issue #1449: Search Photos finds named people, and quotes keep a name together (steps 1 and 2)

This commit is contained in:
aiongg
2026-10-22 02:40:15 +00:00
5 changed files with 115 additions and 9 deletions
+2 -1
View File
@@ -212,7 +212,8 @@ Then tone it with Color Grading for selenium, sepia or split-tone prints.
A library that stays out of your way: **All Photos**, **Recently Added**, **Picks**, **By Date**, **Albums** nested in
**Folders**, and **Recently Deleted**. Rate with <kbd>0</kbd>–<kbd>5</kbd>, flag with <kbd>P</kbd> / <kbd>X</kbd> /
<kbd>U</kbd>, colour-label with <kbd>6</kbd>–<kbd>9</kbd>. Search understands fields:
`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains`. Every view sorts by capture date,
`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains person:"Jane Doe"`; plain words also match
the people named on faces, and quotes keep several words together. Every view sorts by capture date,
import date, edit date, name, rating, size or at random (a stable shuffle; View → Sort → Reshuffle for a new one). The justified **Photo Grid** and **Square Grid** views are virtualized,
so they stay smooth whether you have forty photos or forty thousand.
+41 -6
View File
@@ -141,9 +141,40 @@ pub(crate) struct Prepared {
only: Option<std::collections::HashSet<PhotoId>>,
}
/// The tokens of a search text: split at whitespace, except inside double quotes, so
/// `person:"Jane Doe"` is one token (issue #1449). An unclosed quote runs to the end of the text; a
/// backslash is an ordinary character. One pass over the text, nothing allocated.
fn search_tokens(text: &str) -> impl Iterator<Item = &str> {
let mut rest = text;
std::iter::from_fn(move || {
rest = rest.trim_start();
if rest.is_empty() {
return None;
}
let mut quoted = false;
let end = rest
.char_indices()
.find(|&(_, c)| {
quoted ^= c == '"';
!quoted && c.is_whitespace()
})
.map_or(rest.len(), |(i, _)| i);
let (tok, tail) = rest.split_at(end);
rest = tail;
Some(tok)
})
}
/// `s` without the double quotes around it (a missing closing quote is allowed).
fn unquote(s: &str) -> &str {
s.strip_prefix('"').map_or(s, |inner| inner.strip_suffix('"').unwrap_or(inner))
}
fn token_matches(p: &Photo, tok: &str) -> bool {
let t = tok.to_lowercase();
if let Some((field, val)) = t.split_once(':') {
// a token that starts with a quote is a phrase, even with a colon in it
if let Some((field, quoted)) = t.split_once(':').filter(|_| !t.starts_with('"')) {
let val = unquote(quoted);
let num = |s: &str| -> Option<(char, f64)> {
let (op, rest) = match s.chars().next()? {
c @ ('>' | '<' | '=') => (c, &s[1..]),
@@ -180,18 +211,22 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
"date" => p.captured.as_deref().is_some_and(|c| c.starts_with(val)),
"copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(),
"name" | "file" => p.file_name.to_lowercase().contains(val),
// Not a known field: the colon is part of a plain word ("10:30", "Re:").
// Not a known field: the colon is part of a plain word ("10:30", "Re:"), its value unquoted
// (`re:"a b"` is the text `re:a b`).
_ if val.len() != quoted.len() => text_matches(p, &format!("{field}:{val}")),
_ => text_matches(p, &t),
};
}
text_matches(p, &t)
text_matches(p, unquote(&t))
}
/// Whether the lower-cased word `t` is in the file name, title, caption, camera, lens, location,
/// format or a keyword of `p`.
/// format, a keyword or the name of a person (a named face) of `p`.
fn text_matches(p: &Photo, t: &str) -> bool {
let hay = [&p.file_name, &p.meta.title, &p.meta.caption, &p.meta.camera, &p.meta.lens, &p.meta.location, &p.format];
hay.iter().any(|h| h.to_lowercase().contains(t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(t))
hay.iter().any(|h| h.to_lowercase().contains(t))
|| p.meta.keywords.iter().any(|k| k.to_lowercase().contains(t))
|| p.people().iter().any(|n| n.to_lowercase().contains(t))
}
/// Whether `p` has a named face region called `name` (case-insensitive).
@@ -409,7 +444,7 @@ impl Filter {
{
return false;
}
self.text.split_whitespace().all(|tok| token_matches(p, tok))
search_tokens(&self.text).all(|tok| token_matches(p, tok))
}
}
+65
View File
@@ -836,6 +836,71 @@ fn cmp_name_ignores_case_without_allocating() {
assert_eq!(cmp_name("a", "ab"), Less);
}
/// Search Photos finds named people in free text, and a quoted value keeps a name of several words
/// together: `person:"Jane Doe"` (issue #1449). Malformed quotes narrow the result or change nothing.
#[test]
fn search_text_finds_people_and_quoted_values() {
use lightcraft_meta::{Rect, Region, RegionKind};
let region = |name: &str, kind: RegionKind| Region {
rect: Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 },
kind,
name: Some(name.to_string()),
description: None,
};
let mut c = Catalog::new();
let mut add = |name: &str, regions: Vec<Region>, keywords: &[&str]| {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00");
p.meta.regions = regions;
p.meta.keywords = keywords.iter().map(|k| k.to_string()).collect();
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
id
};
let jane = add("a.jpg", vec![region("Jane Doe", RegionKind::Face), region("Rex", RegionKind::Pet)], &[]);
let both = add("b.jpg", vec![region("Jane Roe", RegionKind::Face), region("John Doe", RegionKind::Face)], &[]);
let zoe = add("c.jpg", vec![region("Zoë Ångström", RegionKind::Face)], &["New York"]);
let none = add("lunch 10:30 am.jpg", vec![], &["york minster"]);
let q = |text: &str| {
let mut ids = c.query(&Filter { text: text.into(), ..Default::default() }, &Sort::default());
ids.sort();
ids
};
// free text: a person's name, any case, any part of it; pets are not people
assert_eq!(q("jane"), vec![jane, both]);
assert_eq!(q("DOE"), vec![jane, both]);
assert_eq!(q("ångström"), vec![zoe]);
assert!(q("rex").is_empty(), "a pet is not a person");
// words are still ANDed: b.jpg has a Jane and a Doe, a quoted phrase asks for them together
assert_eq!(q("jane doe"), vec![jane, both]);
assert_eq!(q("\"jane doe\""), vec![jane]);
// fielded: the exact name, of several words when quoted
assert_eq!(q("person:\"Jane Doe\""), vec![jane]);
assert_eq!(q("who:\"jane roe\""), vec![both]);
assert_eq!(q("person:\"Zoë Ångström\""), vec![zoe]);
assert!(q("person:jane").is_empty(), "person: is the whole name, as before");
assert!(q("person:Jane Doe").is_empty(), "unquoted, the second word is its own token, as before");
assert_eq!(q("keyword:\"new york\""), vec![zoe], "other fields take quoted values too");
assert_eq!(q("person:\"Jane Doe\" a.jpg"), vec![jane], "tokens after a quoted one still count");
// malformed input never widens the match and never panics
assert_eq!(q("person:\"Jane Doe"), vec![jane], "an unclosed quote runs to the end");
assert!(q("person:\"Jane Doe a.jpg").is_empty(), "…and takes the rest of the text with it");
assert!(q("person:\"\"").is_empty());
assert!(q("person:").is_empty());
assert_eq!(q("\""), vec![jane, both, zoe, none], "a lone quote is an empty phrase, like an empty search");
assert_eq!(q("\"\" jane"), vec![jane, both]);
assert!(q("\"jane:doe\"").is_empty(), "a quoted phrase is free text even with a colon");
// an unknown field is plain text, its quoted value unquoted (#2099): `10:"30 am"` is the text `10:30 am`
assert_eq!(q("10:\"30 am\""), vec![none]);
assert!(q("10:\"30 pm\"").is_empty());
assert!(q("jane\\\"").is_empty(), "a backslash is an ordinary character");
let long = format!("person:\"{}", "é ".repeat(50_000));
assert!(q(&long).is_empty());
assert_eq!(q(" \t jane \u{3000} "), vec![jane, both], "any whitespace separates tokens");
}
/// What the catalog remembers of its latest ops: which photos each changed and what of them, as long as that can be
/// said photo by photo.
#[test]
+2 -2
View File
@@ -148,7 +148,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| 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`, `camera:…`); no suggestions dropdown |
| 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`, `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) |
@@ -157,7 +157,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-FILT-KEYWORD | Keyword filter | P1 | ✅ | `cmd:library.filter` (`keyword`), `crates/ui-egui/src/panels/filterbar.rs` | keyword picker |
| LR-FILT-CAMERA | Camera / lens filter | P1 | ✅ | `cmd:library.filter` (`camera`, `lens`), `crates/ui-egui/src/panels/filterbar.rs` | camera and lens pickers |
| LR-FILT-LOCATION | Location filter | P2 | ✅ | `cmd:library.filter` (`text`, `ruleSet` field `location`) | free text, or a rule on location / city / state / country (smart albums, `library.filter`) |
| LR-FILT-PEOPLE | People filter | P2 | 🟡 | `cmd:library.filter`, `crates/catalog/src/query.rs` | filter by a person's name (case-insensitive, named Face regions only) from the People view, a filter chip or `person:`; no unnamed / suggested people |
| LR-FILT-PEOPLE | People filter | P2 | 🟡 | `cmd:library.filter`, `crates/catalog/src/query.rs` | filter by a person's name (case-insensitive, named Face regions only) from the People view, a filter chip, `person:` (`person:"Jane Doe"` for a name of several words) or a plain word in Search Photos (part of the name); no People picker in the filter bar and one person at a time (issue #1449); no unnamed / suggested people |
| LR-FILT-CULL | Culling-score filters | P2 | ✅ | `cmd:library.filter` (`ruleSet` fields `sharpness`, `bestOfGroup`) | Focus and Best of Similar Shots in the rule editor / smart albums |
| LR-FILT-SORT | Sort | P0 | ✅ | `cmd:library.sort`, `cmd:library.shuffle`, `crates/ui-egui/src/panels/bottombar.rs` | Random sort (seeded, stable under edits; choosing Random or Reshuffle picks a new seed); the shuffle is part of the saved view state; no effect in Recently Added (always newest import first); no colour-label or custom (manual) order |
| LR-FILT-SAVED | Filter presets [Classic] | P2 | ✅ | `cmd:filter.savePreset`, `cmd:filter.applyPreset`, `cmd:filter.presets`, `cmd:filter.deletePreset`, `crates/ui-egui/src/panels/filterbar.rs` | filter bar → Presets: apply, save current filter, delete (right-click); saved with the library |
+5
View File
@@ -240,6 +240,11 @@
- A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity
changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged.
### Search finds people
- Typing a person's name in Search Photos finds the photos with that named face (issue #1449).
- Quotes keep several words together: `person:"Jane Doe"` finds exactly that person, `"jane doe"` those words side
by side, and `keyword:"new york"` a keyword of two words.
### Where the camera focused
- **View ▸ Show Focus Point** (⌥F) draws the autofocus frame the camera recorded over the photo, in Detail, Compare
and Survey, following rotation and crops; the Info panel says how it focused (`AF-C · Wide · Animal Eye Tracking`).