diff --git a/README.md b/README.md
index 42b164d6c..adeae67f7 100644
--- a/README.md
+++ b/README.md
@@ -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 0–5, flag with P / X /
U, colour-label with 6–9. 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.
diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs
index 7625c9ab6..a94421369 100644
--- a/crates/catalog/src/query.rs
+++ b/crates/catalog/src/query.rs
@@ -141,9 +141,40 @@ pub(crate) struct Prepared {
only: Option>,
}
+/// 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- {
+ 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))
}
}
diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs
index a32b5acf5..f8b72dad8 100644
--- a/crates/catalog/src/tests.rs
+++ b/crates/catalog/src/tests.rs
@@ -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, 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]
diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md
index c4a94f46e..eaed8cd22 100644
--- a/docs/parity-checklist.md
+++ b/docs/parity-checklist.md
@@ -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 |
diff --git a/docs/whats-new.md b/docs/whats-new.md
index df0f69118..69f70706b 100644
--- a/docs/whats-new.md
+++ b/docs/whats-new.md
@@ -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`).