Files
DarkRoom/ui/dr-ui/ui/identity.slint
T
dtourolleandClaude Opus 5 57c0cc0d35 Keep the name typed into a cluster when the next one is opened
Naming a cluster and moving straight to the next is the gesture this screen
exists for, and it discarded the name every time. Two causes, both in the same
four lines.

`Field.text` is two-way bound to its `TextInput`. Binding it to `selected-name`
therefore works exactly once: the first keystroke writes through the `<=>` and
**replaces** the declarative binding, after which the field follows nothing.
Switching clusters left the previous cluster's half-typed text on screen,
attached to the new person.

And `Field` only reported `accepted`, which is Enter. A name typed and then
abandoned by clicking the next face never reached Rust at all.

So `Field` gains an `edited` callback, the screen keeps the draft with the
person it was typed for, and the draft is written when the selection moves or
the screen closes. The field is then reset from a revision counter the screen
watches.

A counter rather than `changed selected-name`, because the name is not a key:
naming six clusters "Anna" in a row never changes `selected-name`, and the field
would keep the half-typed text from the cluster before. Nor `changed
selected-person`, since accepting a namesake merge lands the user back on a
person they may already have been on.

The draft carries its `PersonId`. A reload can move the selection out from under
a half-typed name — a merge arriving through a sync, a deletion — and applying
it to whoever is selected now would rename a stranger. If the person is gone
when the draft lands, it is dropped rather than resurrecting a row the rail no
longer shows.

`None` and `Some("")` are kept distinct. A user who cleared the field means to
clear the name; a user who never touched it means to leave it alone. Collapsing
those two erases names by walking past them.

An implicit commit does not raise the namesake merge offer. That question is
about a screen the user has already left, and answering it on their behalf while
they look at the next cluster is not a question at all — the offer stays on the
explicit submit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:32:00 +02:00

500 lines
20 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
// The batch check's own words: how much of the library face detection has
// actually been over. Text rather than a number, because "4,812 of 5,000
// indexed, 190 awaiting a proxy" is the useful form and a bare percentage
// hides the reason the rest are outstanding.
in property <string> coverage;
in property <bool> coverage-complete: false;
// 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;
/// Where leaving goes back to — "‹ Library" or "‹ Develop".
///
/// The label follows the behaviour rather than the other way round: this
/// screen is reachable from both of the other two, and a button that said
/// "Library" while returning to develop would be lying about the one thing
/// a back button has to be right about.
in property <string> back-label: "‹ Library";
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();
/// A merge the screen is offering, because the name just typed is already
/// someone else's. Empty when there is nothing to offer.
///
/// Offered rather than performed: `people.uuid` is the identity and the
/// name is not, so two people sharing one is legal and folding them
/// together silently would be the screen making an identity decision on
/// the user's behalf — the thing FR-CULL-10 spends the split button to
/// avoid.
in property <string> merge-offer-name;
in property <int> merge-offer-faces: 0;
callback merge-accept();
callback merge-decline();
/// Every keystroke in the name field, so a name typed and not submitted is
/// not lost when the user moves to the next cluster.
callback name-edited(string);
/// Bumped whenever `selected-name` becomes authoritative for a *new*
/// selection.
///
/// A counter rather than a `changed selected-name` handler, because the
/// name is not a key: naming six clusters "Anna" in a row never changes
/// `selected-name`, and the field would keep the half-typed text from the
/// cluster before. It also cannot be `changed selected-person`, since a
/// merge can land the user back on the person they were already on.
in property <int> name-revision: 0;
callback recluster();
callback index-faces();
callback stop-indexing();
callback check-coverage();
callback delete-all-face-data();
callback close();
// Put the newly-selected person's name in the field, overwriting whatever
// was being typed. The typed text is not discarded — Rust has committed it
// by now, through `name-edited` — and a plain binding cannot do this job:
// `Field.text` is two-way bound to its entry, so the first keystroke
// replaces the binding and the field stops following the selection.
changed name-revision => {
name-field.text = root.selected-name;
}
HorizontalLayout {
// ── the people rail ───────────────────────────────────────────────
Panel {
width: 260px;
VerticalLayout {
padding: Theme.gap;
spacing: Theme.gap-sm;
// "‹ Library" rather than a close cross, matching develop's
// header exactly. This is a screen you *leave for the
// library*, not a dialogue you dismiss, and the two should
// not use different words for the same move.
Button {
text: root.back-label;
clicked => { root.close(); }
}
Text {
text: "Identity Manager";
color: Theme.ink;
font-size: Theme.text-lg;
vertical-alignment: center;
}
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.
// Not wrapped in an `if`: the reset below needs this element
// to exist and keep its name for the life of the screen, and a
// conditional one is a different element each time the
// condition flips.
name-field := Field {
text: root.selected-name;
placeholder: "Name this person";
width: root.selected-person >= 0 ? 240px : 0px;
visible: root.selected-person >= 0;
edited(t) => { root.name-edited(t); }
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(); }
}
if !root.indexing && !root.model-missing && !root.coverage-complete: Button {
text: "Index faces";
clicked => { root.index-faces(); }
}
if root.indexing: Button {
text: "Stop";
clicked => { root.stop-indexing(); }
}
Button {
text: "Regroup";
clicked => { root.recluster(); }
}
}
// The namesake offer. Sits directly under the name field that
// caused it, because it is a question about what was just typed
// and anywhere else it would read as a status line.
if root.merge-offer-name != "": Rectangle {
height: 44px;
border-radius: Theme.radius;
background: Theme.surface-raised;
HorizontalLayout {
padding-left: Theme.gap;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
Text {
text: "Someone else is already called " + root.merge-offer-name
+ " (" + root.merge-offer-faces + " faces). Merge them?";
color: Theme.ink-dim;
font-size: Theme.text-sm;
vertical-alignment: center;
wrap: word-wrap;
}
Rectangle { }
// Decline first and merge second, so the destructive-
// feeling half is not the one under a thumb reaching for
// the edge of the strip.
Button {
text: "Keep separate";
clicked => { root.merge-decline(); }
}
Button {
text: "Merge";
primary: true;
clicked => { root.merge-accept(); }
}
}
}
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;
}
}
// The run-marker check, shown rather than buried in a log. Without
// it the screen can say how many faces it has but not how much of
// the library it has actually looked at — and those are the two
// different questions the face_index table exists to separate.
if root.coverage != "": HorizontalLayout {
spacing: Theme.gap-sm;
Text {
text: root.coverage;
color: root.coverage-complete ? Theme.ink-faint : Theme.ink-dim;
font-size: Theme.text-sm;
vertical-alignment: center;
wrap: word-wrap;
}
Rectangle { }
IconButton {
icon: "rotate-cw";
clicked => { root.check-coverage(); }
}
}
// 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); }
}
}
}
}
}
}
}