LR-LIB-PEOPLE: preserve later person navigation on current main

Port the original Escape return and last-person shortcut behavior, keeping current translations and a no-wrap chip. The existing headless regressions and all seven quality gates pass with the hosted fonts on the refreshed recognition dependency chain.

Generated with Codex.
This commit is contained in:
Daniel Voshart
2026-10-08 22:29:31 -04:00
parent 24f87a91a0
commit 111cc893ec
12 changed files with 76 additions and 13 deletions
+2 -1
View File
@@ -1797,5 +1797,6 @@
"How to install the model": "So installierst du das Modell",
"Install the SAM 3 Model": "SAM-3-Modell installieren",
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Diese Version kann das Modell noch nicht herunterladen. Zum Installieren von Hand lege model.safetensors, vocab.json und merges.txt aus Metas facebook/sam3 in den obigen Ordner: Objekt und Beschreiben funktionieren, sobald sie dort liegen.",
"Open Log Folder": "Protokollordner öffnen"
"Open Log Folder": "Protokollordner öffnen",
"Back to this person": "Back to this person"
}
+2 -1
View File
@@ -1642,5 +1642,6 @@
"Captured from": "Capturadas desde",
"Captured until": "Capturadas hasta",
"Only": "Solo",
"Open Log Folder": "Abrir carpeta de registros"
"Open Log Folder": "Abrir carpeta de registros",
"Back to this person": "Back to this person"
}
+2 -1
View File
@@ -1642,5 +1642,6 @@
"How to install the model": "モデルのインストール方法",
"Install the SAM 3 Model": "SAM 3モデルのインストール",
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "このビルドではまだモデルをダウンロードできません。手動でインストールするには、Meta の facebook/sam3 の model.safetensors、vocab.json、merges.txt を上のフォルダに置いてください。置いた時点で「オブジェクト」と「説明で選択」が使えるようになります。",
"Open Log Folder": "ログフォルダーを開く"
"Open Log Folder": "ログフォルダーを開く",
"Back to this person": "Back to this person"
}
+2 -1
View File
@@ -1642,5 +1642,6 @@
"≤ at most": "≤ no máximo",
"≥ at least": "≥ no mínimo",
"日本語": "日本語",
"繁體中文(台灣)": "繁體中文(台灣)"
"繁體中文(台灣)": "繁體中文(台灣)",
"Back to this person": "Back to this person"
}
+2 -1
View File
@@ -1312,5 +1312,6 @@
"of the following rules:": "из следующих условий:",
"saved in": "сохранено в",
"this photo": "это фото",
"− harder border · + softer border": "− жёстче край · + мягче край"
"− harder border · + softer border": "− жёстче край · + мягче край",
"Back to this person": "Back to this person"
}
+2 -1
View File
@@ -1642,5 +1642,6 @@
"How to install the model": "如何安装模型",
"Install the SAM 3 Model": "安装 SAM 3 模型",
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚无法下载模型。如需手动安装,请将 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的文件夹:放入后即可使用“对象”和“描述”。",
"Open Log Folder": "打开日志文件夹"
"Open Log Folder": "打开日志文件夹",
"Back to this person": "Back to this person"
}
+2 -1
View File
@@ -1642,5 +1642,6 @@
"How to install the model": "如何安裝模型",
"Install the SAM 3 Model": "安裝 SAM 3 模型",
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚無法下載模型。如需手動安裝,請將 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的資料夾:放入後即可使用「物件」與「描述」。",
"Open Log Folder": "開啟記錄資料夾"
"Open Log Folder": "開啟記錄資料夾",
"Back to this person": "Back to this person"
}
+22 -2
View File
@@ -715,10 +715,30 @@ mod tests {
h.step();
h.step();
assert_eq!((h.app.ui.view, h.app.session.active()), (ViewMode::Detail, Some(ids[1])));
// back to the page, then each way out of it
h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), t);
// Escape from that photo goes back to the page, not to the grid
h.request("ui.key", json!({"key": "escape"}), t);
h.step();
assert_eq!((h.app.ui.view, h.app.ui.person_page.as_deref()), (ViewMode::People, Some("Jane Doe")));
// the People button from a photo shows everyone, with the person last left one click away next to the title
h.request("ui.clickWidget", json!({"id": format!("person-face:{}:0", ids[1].0)}), t);
h.step();
h.step();
assert_eq!(h.app.ui.view, ViewMode::Detail);
open_people(&mut h);
assert_eq!((h.app.ui.view, h.app.ui.person_page.clone()), (ViewMode::People, None));
h.step();
let r = h.request("ui.clickWidget", json!({"id": "people:last"}), t);
assert_eq!(r["ok"], true, "{r}");
h.step();
h.step();
assert_eq!((h.app.ui.view, h.app.ui.person_page.as_deref()), (ViewMode::People, Some("Jane Doe")));
// a photo reached any other way goes back to the grid on Escape
h.request("engine.execute", json!({"command": "view.detail"}), t);
h.request("ui.key", json!({"key": "escape"}), t);
h.step();
assert_eq!(h.app.ui.view, ViewMode::PhotoGrid);
h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), t);
h.step();
let r = h.request("ui.clickWidget", json!({"id": "person:back"}), t);
assert_eq!(r["ok"], true, "{r}");
h.step();
+14 -2
View File
@@ -318,8 +318,12 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Ok(json!({"show": app.ui.face_boxes}))
}
"view.people" => {
// Everyone; the page just left stays one click away (the chip next to the title)
if let Some(name) = app.ui.person_page.take() {
app.ui.last_person = Some(name);
}
app.ui.person_from = None;
app.ui.view = ViewMode::People;
app.ui.person_page = None;
Ok(json!({"people": app.session.catalog.people().len()}))
}
"view.person" => {
@@ -328,6 +332,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
return Some(Err("view.person: missing `name`".into()));
};
app.ui.view = ViewMode::People;
app.ui.person_from = None;
app.ui.person_page = Some(name.to_string());
Ok(json!({"person": name}))
}
@@ -381,7 +386,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
} else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) {
app.ui.view = ViewMode::Detail;
} else if app.ui.view == ViewMode::Detail {
app.ui.view = ViewMode::PhotoGrid;
// a photo opened from a person's page goes back to that page, any other to the grid
match app.ui.person_from.take() {
Some((name, photo)) if app.session.active().is_some_and(|a| a.0 == photo) => {
app.ui.view = ViewMode::People;
app.ui.person_page = Some(name);
}
_ => app.ui.view = ViewMode::PhotoGrid,
}
}
Ok(Value::Null)
}
+14 -2
View File
@@ -7,7 +7,7 @@
//! every face is cut from the same kind of box (the detector's, once the scan has looked at it), so faces are shown
//! equally close. Only the rows on screen ask for a face render (the engine caches them, memory and disk).
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use egui::{Align2, Color32, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2};
use lightcraft_catalog::Person;
use serde_json::json;
@@ -65,7 +65,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let thumb = app.ui.thumb_size;
let people = app.caches.people(&app.session.catalog, &app.session.filter);
let (head, _) = ui.allocate_exact_size(vec2(ui.available_width(), HEADER_H), Sense::hover());
ui.painter().text(pos2(head.left() + PAD, head.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Named People"), t.semibold(15.0), t.text);
let title =
ui.painter().text(pos2(head.left() + PAD, head.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Named People"), t.semibold(15.0), t.text);
// the person page last left (a face opened from it, or its back button): one click returns to it
if let Some(last) = app.ui.last_person.clone().filter(|n| people.iter().any(|p| &p.name == n)) {
let label = fit(ui.painter(), &format!("{last} ›"), t.font(13.0), 240.0);
let width = ui.painter().layout_no_wrap(label.clone(), t.font(13.0), Color32::WHITE).size().x + 24.0;
let r = Rect::from_min_size(pos2(title.right() + 14.0, head.center().y - 12.0), vec2(width, 24.0));
let b = ui.put(r, egui::Button::new(RichText::new(label).font(t.font(13.0))).wrap_mode(egui::TextWrapMode::Extend));
register(ui.ctx(), "people:last", b.rect);
if b.on_hover_text(crate::i18n::tr("Back to this person")).clicked() {
let _ = app.run("view.person", json!({"name": last}));
}
}
ui.painter().text(pos2(head.right() - PAD, head.center().y), Align2::RIGHT_CENTER, people.len().to_string(), t.font(13.0), t.text_dim);
// until face recognition is set up: what it takes, and a button that does the next step
super::faces::setup_banner(app, ui);
+4
View File
@@ -208,6 +208,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) {
Hit::Open => {
let _ = app.run("library.select", json!({"ids": [f.photo], "active": f.photo}));
let _ = app.run("view.detail", json!({}));
// Escape comes back to this page; the People button to everyone, with this person next to its title
app.ui.person_from = Some((shown_name.clone(), f.photo));
app.ui.last_person = Some(shown_name.clone());
app.ui.person_page = None;
}
Hit::Confirm => {
let _ = app.run("faces.setName", json!({"id": f.photo, "index": f.index, "name": shown_name}));
+8
View File
@@ -338,6 +338,12 @@ pub struct UiState {
/// The person whose page the People view shows (their faces and the faces that look like them); `None`: everyone.
#[serde(skip)]
pub person_page: Option<String>,
/// The person page a photo in the loupe was opened from, and that photo: Escape goes back to the page (not the grid).
#[serde(skip)]
pub person_from: Option<(String, u64)>,
/// The last person page left: the People view offers a way back to it next to its title.
#[serde(skip)]
pub last_person: Option<String>,
/// "More" faces the user hid with ×, for this session: (photo, region index).
#[serde(skip)]
pub dismissed_faces: std::collections::HashSet<(u64, usize)>,
@@ -687,6 +693,8 @@ impl Default for UiState {
collapsed_sidebar: Vec::new(),
name_edit: None,
person_page: None,
person_from: None,
last_person: None,
dismissed_faces: Default::default(),
unnamed_selected: Default::default(),
unnamed_name: String::new(),