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:
@@ -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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user