Add the Identity screen

A third top-level screen beside the library and develop, because naming a
cluster and pulling a stranger out of it are tasks with their own rhythm
and need the whole window.

The screen is designed around the clustering being wrong, which is
FR-CULL-10 rather than pessimism: grouping over-merges on siblings, on
parents and children, and on the same person a decade apart. So Split off
sits next to Confirm all rather than behind a menu, the confirm/reject
pair is on the face itself, and a group the system found is drawn
differently from a person the user has vouched for.

Splitting rejects before it confirms. Without that the next clustering
pass suggests the face straight back and the user's correction becomes an
argument they keep having.

Face crops come from the proxies the grid already built, one decode per
image rather than per face -- a group photograph holding six faces of one
family is one JPEG.

Where the calibration is not fitted the screen says confidence is
unavailable instead of printing a percentage that looks measured, which
is FR-CULL-9's rule at the point it becomes visible.

The verdict controls use drawn icons, not tick and cross characters:
ui/icons.slint exists because those render as tofu on Android.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 21:05:41 +02:00
co-authored by Claude Opus 5
parent 2ac069a6b3
commit 10b10569e2
8 changed files with 1647 additions and 42 deletions
+63 -3
View File
@@ -2,6 +2,7 @@ import { Theme } from "theme.slint";
import { AdjustPanel, GeometryPanel, ModeStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint";
import { GradientHandle, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint";
import { LaunchScreen } from "launch.slint";
import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.slint";
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow } from "library.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
@@ -516,6 +517,36 @@ export component AppWindow inherits Window {
/// the page is reachable from the launch screen too.
in property <bool> library-thumbnailing: false;
in property <bool> library-open: false;
// --- people and faces (FR-CULL-8 … FR-CULL-12) ---
//
// A third top-level screen rather than a panel: naming a cluster and
// pulling a stranger out of it are tasks with their own rhythm, and they
// need the whole window. Gated the same way the other screens are, so
// exactly one is ever up.
in property <bool> show-identity: false;
in property <[IdentityPerson]> identity-people;
in property <[IdentityFace]> identity-faces;
in property <int> identity-selected: -1;
in property <string> identity-selected-name;
in property <int> identity-unassigned: 0;
in property <bool> identity-calibrated: false;
in property <bool> identity-indexing: false;
in property <string> identity-indexing-status;
in property <bool> identity-model-missing: false;
in property <int> identity-picked: 0;
callback identity-open();
callback identity-close();
callback identity-person-picked(int);
callback identity-rename(string);
callback identity-confirm-face(int);
callback identity-reject-face(int);
callback identity-toggle-pick(int);
callback identity-confirm-all();
callback identity-split-picked();
callback identity-recluster();
callback identity-delete-all();
callback library-thumbnail-all();
in-out property <length> library-cell-size: 180px;
/// Whether this session is a touch one, which decides if the rating strip
@@ -1236,9 +1267,37 @@ in property <bool> panel-visible: true;
reset-defaults() => { root.settings-reset(); }
}
// Like the launch screen, this replaces the window rather than
// overlaying it — see `show-identity`.
if !root.show-import && !root.show-settings && root.show-identity: IdentityScreen {
width: 100%;
height: 100%;
people: root.identity-people;
faces: root.identity-faces;
selected-person: root.identity-selected;
selected-name: root.identity-selected-name;
unassigned: root.identity-unassigned;
calibrated: root.identity-calibrated;
indexing: root.identity-indexing;
indexing-status: root.identity-indexing-status;
model-missing: root.identity-model-missing;
picked-count: root.identity-picked;
person-picked(id) => { root.identity-person-picked(id); }
rename(name) => { root.identity-rename(name); }
confirm-face(id) => { root.identity-confirm-face(id); }
reject-face(id) => { root.identity-reject-face(id); }
toggle-pick(id) => { root.identity-toggle-pick(id); }
confirm-all() => { root.identity-confirm-all(); }
split-picked() => { root.identity-split-picked(); }
recluster() => { root.identity-recluster(); }
delete-all-face-data() => { root.identity-delete-all(); }
close() => { root.identity-close(); }
}
// The launch screen replaces the whole window rather than overlaying it:
// there is no library to look at until an account is configured.
if !root.show-import && !root.show-settings && root.show-launch: LaunchScreen {
if !root.show-import && !root.show-settings && !root.show-identity && root.show-launch: LaunchScreen {
width: 100%;
height: 100%;
signed-in: root.launch-signed-in;
@@ -1278,7 +1337,7 @@ in property <bool> panel-visible: true;
// has been opened but no image chosen yet. The collections sidebar and the
// grid are siblings here rather than the sidebar living inside the grid,
// because the drag that connects them has to be owned above both.
if !root.show-import && !root.show-settings && !root.show-launch && root.show-library: Rectangle {
if !root.show-import && !root.show-settings && !root.show-identity && !root.show-launch && root.show-library: Rectangle {
width: 100%;
height: 100%;
background: Theme.ground;
@@ -1421,6 +1480,7 @@ in property <bool> panel-visible: true;
can-import: root.import-supported;
open-import() => { root.import-open(); }
open-settings() => { root.settings-open(); }
open-people() => { root.identity-open(); }
cell-pressed(i, ctrl, shift) => {
root.library-cell-pressed(i, ctrl, shift);
@@ -1486,7 +1546,7 @@ in property <bool> panel-visible: true;
}
}
if !root.show-import && !root.show-settings && !root.show-launch && !root.show-library: VerticalLayout {
if !root.show-import && !root.show-settings && !root.show-identity && !root.show-launch && !root.show-library: VerticalLayout {
width: 100%;
height: 100%;
+377
View File
@@ -0,0 +1,377 @@
// TRACES: FR-CULL-10 | FR-CULL-11 | FR-CULL-12 | NFR-SEC-5
//
// The Identity screen: who the library knows, and how the user corrects it.
//
// A third top-level screen beside the library and develop, because it is a
// place you go to *work*. Naming a cluster, pulling a stranger out of it, and
// merging two halves of the same person have their own rhythm and need the
// whole window.
//
// # The screen is designed around the clustering being wrong
//
// That is FR-CULL-10, not pessimism: grouping over-merges on siblings, on
// parents and children, and on the same person a decade apart. So **split is
// as prominent as merge**, a suggestion always looks like a suggestion, and
// the confirm/reject pair sits on the face itself rather than behind a menu.
import { Theme } from "theme.slint";
import { Panel, Button, IconButton, Field } from "widgets.slint";
import { Icon } from "icons.slint";
export struct IdentityPerson {
id: int,
// What to draw. Empty name becomes "Unnamed (n faces)" on the Rust side,
// so the fallback is written once rather than in both languages.
label: string,
// True while the group is entirely the system's opinion. Drawn differently
// because an unreviewed guess and a person the user has vouched for are
// not the same kind of thing, and the rail is where that difference is
// cheapest to show.
unconfirmed: bool,
confirmed-faces: int,
suggested-faces: int,
cover: image,
has-cover: bool,
}
export struct IdentityFace {
id: int,
crop: image,
has-crop: bool,
confirmed: bool,
// "Confirmed", "83% likely", or "Confidence unavailable" — composed in
// Rust, because FR-CULL-9's rule about not showing an untuned number as
// though it were measured is a rule about *content*, and it should not be
// re-derived from a float in a second place.
confidence: string,
// Source pixels across the aligned crop. A suggestion the user disagrees
// with has causes, and "the face was 41 pixels across" is one the user can
// act on by finding a better photograph.
crop-px: int,
// Part of the current multi-select — what a split would carry.
picked: bool,
}
// One face, with its verdict controls.
component FaceCell inherits Rectangle {
in property <IdentityFace> face;
in property <bool> compact: false;
callback confirm();
callback reject();
callback toggle-pick();
property <length> edge: root.compact ? 96px : 128px;
width: edge;
height: edge + 34px;
background: face.picked ? Theme.selected : transparent;
border-radius: Theme.radius;
border-width: face.picked ? 1px : 0;
border-color: Theme.selected-ring;
VerticalLayout {
padding: 3px;
spacing: 3px;
Rectangle {
height: root.edge - 6px;
clip: true;
border-radius: Theme.radius-sm;
background: Theme.surface-raised;
if face.has-crop: Image {
source: face.crop;
width: 100%;
height: 100%;
image-fit: cover;
}
// A face whose proxy has been evicted is still a real face and
// still confirmable. Drawing nothing at all would read as a bug.
if !face.has-crop: Text {
text: "no preview";
color: Theme.ink-faint;
font-size: Theme.text-sm;
horizontal-alignment: center;
vertical-alignment: center;
}
// A confirmed face carries a quiet marker rather than a badge: the
// grid is mostly confirmed once the user has worked through it, and
// a loud mark on the common case is just noise.
if face.confirmed: Rectangle {
x: parent.width - self.width - 4px;
y: 4px;
width: 16px;
height: 16px;
border-radius: 8px;
background: Theme.active;
Icon {
name: "check";
ink: Theme.ground;
size: 11px;
}
}
TouchArea {
clicked => { root.toggle-pick(); }
}
}
HorizontalLayout {
spacing: 2px;
alignment: center;
// Only a suggestion needs ruling on. Once confirmed, the pair
// collapses to the label — there is nothing left to decide, and
// leaving the buttons would invite an accidental un-confirm.
if !face.confirmed: IconButton {
icon: "check";
clicked => { root.confirm(); }
}
if !face.confirmed: IconButton {
icon: "cross";
clicked => { root.reject(); }
}
if face.confirmed: Text {
text: face.confidence;
color: Theme.ink-faint;
font-size: Theme.text-sm;
vertical-alignment: center;
}
}
}
}
export component IdentityScreen inherits Rectangle {
background: Theme.ground;
in property <[IdentityPerson]> people;
in property <[IdentityFace]> faces;
in property <int> selected-person: -1;
in property <string> selected-name;
// Faces belonging to nobody. Shown as a count rather than hidden, because
// "why is this photograph not under anyone" deserves an answer.
in property <int> unassigned: 0;
// Whether the library's similarity calibration is fitted (FR-CULL-9).
in property <bool> calibrated: false;
in property <bool> indexing: false;
in property <string> indexing-status;
// No model on disk: face indexing cannot run at all (docs/faces.md §2.2).
in property <bool> model-missing: false;
in property <int> picked-count: 0;
callback person-picked(int);
callback rename(string);
callback confirm-face(int);
callback reject-face(int);
callback toggle-pick(int);
callback confirm-all();
callback split-picked();
callback merge-into(int);
callback recluster();
callback delete-all-face-data();
callback close();
HorizontalLayout {
// ── the people rail ───────────────────────────────────────────────
Panel {
width: 260px;
VerticalLayout {
padding: Theme.gap;
spacing: Theme.gap-sm;
HorizontalLayout {
Text {
text: "People";
color: Theme.ink;
font-size: Theme.text-lg;
vertical-alignment: center;
}
Rectangle { }
IconButton {
icon: "cross";
clicked => { root.close(); }
}
}
if root.unassigned > 0: Text {
text: root.unassigned + " face(s) not yet grouped";
color: Theme.ink-faint;
font-size: Theme.text-sm;
wrap: word-wrap;
}
Flickable {
VerticalLayout {
spacing: 2px;
alignment: start;
for p[i] in root.people: Rectangle {
height: 52px;
border-radius: Theme.radius;
background: p.id == root.selected-person
? Theme.selected
: (touch.has-hover ? Theme.hover : transparent);
HorizontalLayout {
padding: 6px;
spacing: Theme.gap-sm;
Rectangle {
width: 40px;
height: 40px;
border-radius: Theme.radius-sm;
background: Theme.surface-raised;
clip: true;
if p.has-cover: Image {
source: p.cover;
width: 100%;
height: 100%;
image-fit: cover;
}
}
VerticalLayout {
alignment: center;
spacing: 1px;
Text {
text: p.label;
color: p.unconfirmed ? Theme.ink-dim : Theme.ink;
font-size: Theme.text;
overflow: elide;
}
Text {
text: p.suggested-faces > 0
? p.confirmed-faces + " confirmed · " + p.suggested-faces + " suggested"
: p.confirmed-faces + " confirmed";
color: Theme.ink-faint;
font-size: Theme.text-sm;
overflow: elide;
}
}
}
touch := TouchArea {
clicked => { root.person-picked(p.id); }
}
}
}
}
Rectangle { }
// The NFR-SEC-5 control lives here rather than three levels
// down in settings: this is where the user's face data
// visibly is, and a delete-everything button they cannot find
// is a button that does not really exist.
Button {
text: "Delete all face data";
clicked => { root.delete-all-face-data(); }
}
}
}
// ── the faces ─────────────────────────────────────────────────────
VerticalLayout {
padding: Theme.gap;
spacing: Theme.gap-sm;
// Header: who is selected, and what can be done to them.
HorizontalLayout {
spacing: Theme.gap-sm;
height: 32px;
// Naming a cluster *is* the primary action of this screen, so
// the name is an editable field on sight rather than something
// reached through a rename command.
if root.selected-person >= 0: Field {
text: root.selected-name;
placeholder: "Name this person";
width: 240px;
accepted(t) => { root.rename(t); }
}
if root.selected-person < 0: Text {
text: "Select a person";
color: Theme.ink-dim;
font-size: Theme.text-lg;
vertical-alignment: center;
}
Rectangle { }
if root.picked-count > 0: Text {
text: root.picked-count + " selected";
color: Theme.ink-dim;
font-size: Theme.text-sm;
vertical-alignment: center;
}
// Split is a first-class button sitting next to confirm, not a
// command hidden in a menu. FR-CULL-10: a tool that can only
// merge makes its own errors permanent.
if root.picked-count > 0: Button {
text: "Split off";
primary: true;
clicked => { root.split-picked(); }
}
if root.selected-person >= 0 && root.picked-count == 0: Button {
text: "Confirm all";
primary: true;
clicked => { root.confirm-all(); }
}
Button {
text: "Regroup";
clicked => { root.recluster(); }
}
}
if root.model-missing: Rectangle {
height: 40px;
border-radius: Theme.radius;
background: Theme.surface-raised;
Text {
text: "No face model installed — indexing is off.";
color: Theme.warn-ink;
font-size: Theme.text-sm;
}
}
// FR-CULL-9, made visible: where the calibration is not fitted the
// screen says the confidences are unavailable rather than letting
// per-face percentages imply a measurement that was never made.
if !root.calibrated && !root.model-missing: Text {
text: "Similarity is not calibrated for this library yet, so no confidence is shown.";
color: Theme.ink-faint;
font-size: Theme.text-sm;
wrap: word-wrap;
}
if root.indexing: Text {
text: root.indexing-status;
color: Theme.ink-dim;
font-size: Theme.text-sm;
}
Flickable {
VerticalLayout {
alignment: start;
HorizontalLayout {
// Slint has no flow layout, so the grid is a wrapping
// row built from the model's own order; the cells are
// fixed-size, which is what makes that tractable.
spacing: Theme.gap-sm;
alignment: start;
for f[i] in root.faces: FaceCell {
face: f;
confirm => { root.confirm-face(f.id); }
reject => { root.reject-face(f.id); }
toggle-pick => { root.toggle-pick(f.id); }
}
}
}
}
}
}
}
+12
View File
@@ -866,6 +866,7 @@ component HeaderActions inherits HorizontalLayout {
callback rescan();
callback open-import();
callback open-settings();
callback open-people();
spacing: Theme.gap;
@@ -1016,6 +1017,14 @@ component HeaderActions inherits HorizontalLayout {
clicked => { root.open-import(); }
}
// A library action rather than a setting, so it sits with the others and
// ahead of Settings, which the comment below keeps last.
Button {
text: "People";
y: root.centred ? (root.row-height - self.height) / 2 : 0;
clicked => { root.open-people(); }
}
// Last in the row, and unconditional. The buttons before it come and go
// with what the grid is showing; settings is always reachable, and a
// control that moved as its neighbours appeared would be hunted for each
@@ -1165,6 +1174,7 @@ export component LibraryGrid inherits Rectangle {
callback open-import();
callback open-settings();
callback open-people();
// --- selection and drag ---
//
@@ -1701,6 +1711,7 @@ export component LibraryGrid inherits Rectangle {
rescan => { root.rescan(); }
open-import => { root.open-import(); }
open-settings => { root.open-settings(); }
open-people => { root.open-people(); }
}
// Compact: one button in place of six. Labelled rather than a
@@ -1785,6 +1796,7 @@ export component LibraryGrid inherits Rectangle {
rescan => { root.rescan(); }
open-import => { root.open-import(); }
open-settings => { root.open-settings(); }
open-people => { root.open-people(); }
}
}
}