An audit of every action by view against the keys the handlers bind left develop without zoom, pan, fit or a way back to the grid, the grid without select-none, thumbnail size or keywording, People with no key at all, and the export and copy sheets without Enter. It also found the reverse gap FR-UI-5 forbids: pick and reject had no route but P, X and U, and the 2026-09-19 amendment's judging in develop had not been built. Develop: Ctrl+= and Ctrl+Plus zoom in and Ctrl+- out about the middle of the view, Ctrl+0 fits and Ctrl+1 goes to 1:1, Shift and an arrow pan a magnified view, G goes back to the grid, Ctrl+Y redoes, and Enter keeps a crop that hid a mask. 0-5, P, X and U rate and flag the open photograph without moving on, with stars and Pick/Reject in the top bar as the pointer and touch route. = and - nudge the control last moved by a hundredth of its travel; the framing sliders, perspective included, now count as "last moved", so R puts them back as well. J turns the selected mask part's join chip. Grid: Ctrl+D and Ctrl+Shift+A clear the selection, = and - resize the thumbnails, Ctrl+K opens keywording, and Flag in the selection bar gives pick and reject a pointer and touch route. Sidebar: Enter commits a collection's name, and Enter or Escape hands the keyboard back to the grid, where it used to go nowhere until something was clicked. People: Up and Down walk the rail, F2 puts the name field under the keys, and Escape or Back now leave the screen the way its back button does instead of doing nothing. Sheets: Enter does what the export or copy sheet's button does. The choices follow Lightroom where it has one. No new key steals typing: the grid's and People's keys live on focus holders that are not ancestors of any text field, and the sheets' Enter comes after a focused field has had it. Every binding is tagged beside its handler, and the gate added in the previous commit holds the two to each other.
984 lines
46 KiB
Plaintext
984 lines
46 KiB
Plaintext
// 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 { Keys } from "keys.slint";
|
||
import { Panel, Button, IconButton, Field, ListView } from "widgets.slint";
|
||
import { SliderRow } from "controls.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,
|
||
// Set aside by the user: correctly found, correctly grouped, and not
|
||
// someone they want to identify. Hidden from the rail unless they ask.
|
||
ignored: bool,
|
||
}
|
||
|
||
export struct IdentityFace {
|
||
id: int,
|
||
crop: image,
|
||
has-crop: bool,
|
||
confirmed: bool,
|
||
// "Confirmed" or "83% likely" — composed in Rust, because FR-CULL-9's rule
|
||
// about how a confidence is described 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,
|
||
// "Quality 17.3" — the model's own reading of how recognisable the crop
|
||
// was, composed in Rust like `confidence` and for the same reason. The
|
||
// other cause a bad suggestion can have, and the one the grouping pass
|
||
// acts on: a face below the floor is placed but never compared against.
|
||
quality: string,
|
||
// Whether the face clears that floor. Drawn, not just known, because a
|
||
// group that has gathered none of a person's other photographs has an
|
||
// explanation the user can only see if every member's label says it.
|
||
in-gallery: bool,
|
||
// TRACES: FR-CULL-8a | FR-CULL-13
|
||
// "Eyes closed", "Sunglasses" or "Eyes unclear", or empty — composed in
|
||
// Rust like the two above. Empty for open eyes and for a face never
|
||
// read, so the mark is only ever the reason a frame is or is not under
|
||
// the eyes-open chip.
|
||
eyes: string,
|
||
// 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();
|
||
|
||
/// The drawn size, set by the grid that lays these out.
|
||
///
|
||
/// An `in` property with a default rather than a derived one: the grid
|
||
/// divides its width into whole columns and tells each cell what it came
|
||
/// to, exactly as the library grid does. The default is what a caller that
|
||
/// does not care still gets.
|
||
in property <length> edge: root.compact ? 96px : 128px;
|
||
|
||
/// Height of the caption strip under the crop. Named because the grid has
|
||
/// to add it to the row pitch, and a grid using a different number from
|
||
/// the cell is a grid whose rows creep.
|
||
out property <length> caption-height: 34px;
|
||
|
||
width: edge;
|
||
height: edge + caption-height;
|
||
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;
|
||
}
|
||
|
||
// The quality, over the foot of the crop. In the picture rather
|
||
// than the caption because the caption is the verdict controls'
|
||
// and at the compact width there is no room beside them; and
|
||
// dimmed below the floor, which is the one state of it that
|
||
// changes what the grouping does.
|
||
Rectangle {
|
||
x: 0;
|
||
y: parent.height - self.height;
|
||
width: parent.width;
|
||
height: 16px;
|
||
background: Theme.ground.with-alpha(0.55);
|
||
Text {
|
||
text: face.quality;
|
||
color: face.in-gallery ? Theme.ink-dim : Theme.warn-ink;
|
||
font-size: Theme.text-sm;
|
||
horizontal-alignment: center;
|
||
vertical-alignment: center;
|
||
width: parent.width;
|
||
height: parent.height;
|
||
}
|
||
}
|
||
|
||
// The eye state, over the head of the crop, and only where it
|
||
// is a blink, sunglasses or eyes too soft to read: the readings
|
||
// the eyes-open filter treats differently from open. Open eyes
|
||
// carry no mark for the reason the confirmed marker below gives.
|
||
if face.eyes != "": Rectangle {
|
||
x: 0;
|
||
y: 0;
|
||
width: parent.width;
|
||
height: 16px;
|
||
background: Theme.ground.with-alpha(0.55);
|
||
Text {
|
||
text: face.eyes;
|
||
color: Theme.warn-ink;
|
||
font-size: Theme.text-sm;
|
||
horizontal-alignment: center;
|
||
vertical-alignment: center;
|
||
width: parent.width;
|
||
height: parent.height;
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
}
|
||
|
||
// GESTURE: Pull a face out of the wrong person
|
||
// where: People
|
||
// touch: Tap the faces that do not belong, then "Split off"
|
||
// pointer: Click the faces that do not belong, then "Split off"
|
||
// why: Grouping over-merges on siblings, on parents and
|
||
// children, and on the same person a decade apart, so
|
||
// splitting is as prominent as merging. A tool that can
|
||
// only merge makes its own errors permanent.
|
||
// manual: people
|
||
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.
|
||
// GESTURE: Rule on a suggested face
|
||
// where: People
|
||
// touch: Tick to confirm it, cross to reject it
|
||
// pointer: Tick to confirm it, cross to reject it
|
||
// why: A face is either the system's guess or the user's
|
||
// judgement, and the two are never conflated. A
|
||
// rejection is remembered, so the face is not suggested
|
||
// for that person again.
|
||
// The gesture note above is the whole label: a tick and a cross
|
||
// are only "confirm" and "reject" to someone who can see the
|
||
// suggestion they sit beside, and `IconButton`'s fallback would
|
||
// announce them as "check" and "cross" — two icon names that say
|
||
// nothing about which person is being ruled on.
|
||
// manual: people
|
||
if !face.confirmed: IconButton {
|
||
icon: "check";
|
||
label: "Confirm this face";
|
||
clicked => { root.confirm(); }
|
||
}
|
||
if !face.confirmed: IconButton {
|
||
icon: "cross";
|
||
label: "Reject this face";
|
||
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;
|
||
/// TRACES: FR-UI-1
|
||
/// The narrow layout. Follows window width, not device type — a narrow
|
||
/// desktop window gets it exactly as a phone does. Set from Rust beside
|
||
/// `layout-class`, for the reason app.slint gives: a width read inside the
|
||
/// layout that also feeds it is a binding loop.
|
||
in property <bool> compact: false;
|
||
in property <int> selected-person: -1;
|
||
in property <string> selected-name;
|
||
/// Whether the selected person is set aside, so one button can be both
|
||
/// "Not interested" and "Bring back" — two buttons where only one is ever
|
||
/// applicable is a row that teaches the user to ignore half of it.
|
||
in property <bool> selected-ignored: false;
|
||
// 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 similarity curve was fitted from this library's own faces,
|
||
// as against the built-in one (FR-CULL-9). Changes what the screen says
|
||
// about the confidences, never whether they are shown.
|
||
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;
|
||
/// TRACES: FR-CULL-8a
|
||
/// Every image has been through the detector and only faces are left to
|
||
/// read — the eye pass over a library indexed before the eye models
|
||
/// existed. The same sweep, fetching the same originals; the button says
|
||
/// what it will actually do.
|
||
in property <bool> coverage-read-only: false;
|
||
// No model on disk: face indexing cannot run at all (docs/dev/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);
|
||
/// TRACES: FR-UI-5
|
||
/// The person above (-1) or below (1) the selected one on the rail — the
|
||
/// arrow keys. Rust walks the rail, because the selection is an id and
|
||
/// only Rust can find it in the list.
|
||
callback person-stepped(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;
|
||
/// Regrouping is running. It is off the UI thread, so the screen stays
|
||
/// live and has to say what it is doing rather than simply stopping.
|
||
in property <bool> regrouping: false;
|
||
in property <string> regroup-status;
|
||
/// Whether the grouping dials are open.
|
||
///
|
||
/// Private to the screen: nothing in Rust needs to know, and a disclosure
|
||
/// state that round-tripped through a callback would flicker.
|
||
property <bool> grouping-open: false;
|
||
/// People the user has set aside, and whether the rail is showing them.
|
||
in property <int> ignored-count: 0;
|
||
in property <bool> show-ignored: false;
|
||
callback toggle-show-ignored();
|
||
/// Set a person aside, or bring them back.
|
||
callback ignore-person(int, bool);
|
||
/// Show every photograph this person appears in, in the library grid.
|
||
///
|
||
/// The flag is whether to *add* to whoever the grid is already narrowed
|
||
/// to, rather than replace them — which is how a union or an intersection
|
||
/// of several people gets built without a person-picker of its own.
|
||
callback show-photos(int, bool);
|
||
/// Whether the library grid is already narrowed to somebody, so the
|
||
/// "and also" button only appears when there is something to add to.
|
||
in property <bool> photos-filtered: false;
|
||
callback recluster();
|
||
/// TRACES: FR-CULL-9
|
||
/// How sure the pass has to be before it calls two groups one person, as a
|
||
/// percentage — the same units every confidence on this screen is printed
|
||
/// in, because a control in *cosines* would be the one place in the
|
||
/// subsystem that thresholds a bare similarity.
|
||
in property <float> merge-probability: 80;
|
||
/// The smallest group the pass will make a person out of.
|
||
in property <int> min-group-size: 2;
|
||
callback merge-probability-changed(float);
|
||
callback min-group-size-changed(int);
|
||
/// Ask what the dials would do, without doing it.
|
||
callback preview-grouping();
|
||
in property <bool> previewing: false;
|
||
/// The answer, in words. Empty until one has been asked for.
|
||
in property <string> grouping-preview;
|
||
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;
|
||
}
|
||
|
||
// GESTURE: Move between people
|
||
// where: People
|
||
// touch: Tap a person on the rail
|
||
// pointer: Click a person on the rail
|
||
// keys: `Up` and `Down`, along the rail
|
||
//
|
||
// GESTURE: Name a person
|
||
// where: People
|
||
// touch: Tap the name field over their faces, type, and press Enter
|
||
// pointer: Click the name field over their faces, type, and press Enter
|
||
// keys: `F2` puts the name field under the keys
|
||
// why: The rename key everywhere else. Enter finishes the name and
|
||
// hands the keys back to the rail, so naming a run of people
|
||
// is `Down`, `F2`, a name, Enter, over and over.
|
||
//
|
||
// TRACES: FR-UI-5
|
||
// The screen's keys, on a holder of no size that takes focus on show, as
|
||
// the grid's judgement keys do: without a focus item nothing on this
|
||
// screen hears a key at all — Escape included, which bubbles from here to
|
||
// the shell and leaves. Not an ancestor of the name field, so the arrows
|
||
// move the caret there rather than the selection.
|
||
screen-keys := FocusScope {
|
||
width: 0px;
|
||
height: 0px;
|
||
init => { self.focus(); }
|
||
|
||
// KEYMAP: People
|
||
key-pressed(event) => {
|
||
if (Keys.chord(event) == "Up") {
|
||
root.person-stepped(-1);
|
||
return accept;
|
||
}
|
||
if (Keys.chord(event) == "Down") {
|
||
root.person-stepped(1);
|
||
return accept;
|
||
}
|
||
if (Keys.chord(event) == "F2" && root.selected-person >= 0) {
|
||
name-field.take-focus();
|
||
return accept;
|
||
}
|
||
return reject;
|
||
}
|
||
}
|
||
|
||
HorizontalLayout {
|
||
// ── the people rail ───────────────────────────────────────────────
|
||
Panel {
|
||
// 260px of a 360px phone is the screen. The rail still has to
|
||
// carry a portrait and two lines of text, so it narrows rather
|
||
// than disappearing — 132px keeps the 40px cover and elides the
|
||
// labels, and leaves the faces grid enough width for two columns.
|
||
width: root.compact ? 132px : 260px;
|
||
// The rail scrolls, so its column has to be given the panel's
|
||
// height rather than the sum of its parts — see `Panel.fill`.
|
||
fill: true;
|
||
|
||
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;
|
||
}
|
||
|
||
// A `ListView`, so the rail costs what is on screen rather than
|
||
// what the library holds. See `widgets.slint` — and heed its
|
||
// one rule: **the row height below is a constant**, and the
|
||
// model arrives already filtered. Hiding a row by collapsing it
|
||
// to 0px, which is what this did, is a height that reads the
|
||
// model, and it makes Slint build all of them anyway.
|
||
//
|
||
// A direct child of this layout, with nothing wrapped around
|
||
// it. `ListView` carries its own stretch and preferred size —
|
||
// it has to, having no natural height to offer — and putting a
|
||
// plain Rectangle in between hands the layout that Rectangle's
|
||
// constraints instead, which are taken from the list and are
|
||
// therefore nothing. The rail comes out empty, with every
|
||
// person still in the model.
|
||
ListView {
|
||
for p[i] in root.people: Rectangle {
|
||
// 52 of a row, then the 2px that used to be the
|
||
// layout's `spacing`. Virtualisation places row N
|
||
// at N × this, so the gap lives inside the row.
|
||
height: 54px;
|
||
|
||
Rectangle {
|
||
y: 0px;
|
||
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); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// No spacer between the rail and the buttons below it. There
|
||
// used to be one, to push them to the bottom of a short rail;
|
||
// the rail now stretches into that space itself, and a second
|
||
// stretching child would halve the list to make room for
|
||
// nothing.
|
||
|
||
// What the "not interested" button has hidden, and the way
|
||
// back to it. A count with no way to reveal it would make the
|
||
// action feel like a deletion, which is exactly what it is
|
||
// not.
|
||
if root.ignored-count > 0: Button {
|
||
text: root.show-ignored
|
||
? "Hide " + root.ignored-count + " set aside"
|
||
: "Show " + root.ignored-count + " set aside";
|
||
clicked => { root.toggle-show-ignored(); }
|
||
}
|
||
|
||
// 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.
|
||
//
|
||
// Two rows rather than one. The action row has grown — show
|
||
// photos, set aside, confirm, split, index, regroup — and a
|
||
// 240px name field beside five buttons does not fit a phone. Worse
|
||
// than not fitting: **a layout cannot be narrower than its
|
||
// children's minimums**, so an overflowing row reports that
|
||
// oversized minimum upwards and inflates the whole screen, which
|
||
// is the fault library.slint's filter bar documents at length. The
|
||
// faces grid is a sibling of this row, so it would have been laid
|
||
// out against a width that was never on the screen.
|
||
//
|
||
// **Neither row states a height.** The first version of this pinned
|
||
// them to 32px and 36px, which are smaller than what they contain:
|
||
// a `Field` is `Theme.touch-target` (44px) tall and a `Button` is
|
||
// `Theme.control-height`. Slint honours the child's own height and
|
||
// lets it overflow the box it was given, so the name field ran 12px
|
||
// past its row and straight through the buttons 6px below it. Rows
|
||
// take the height of what is in them; the strip takes the height of
|
||
// a control.
|
||
HorizontalLayout {
|
||
spacing: Theme.gap-sm;
|
||
|
||
// 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";
|
||
// Capped by what there is, so a narrow window shortens the
|
||
// field instead of pushing the row past the edge.
|
||
width: root.selected-person >= 0
|
||
? min(240px, root.width - 2 * Theme.gap)
|
||
: 0px;
|
||
visible: root.selected-person >= 0;
|
||
edited(t) => { root.name-edited(t); }
|
||
// Enter *finishes* naming this person, so the field lets
|
||
// the keyboard go with it. Without this the entry keeps
|
||
// focus after the name is committed, and on a tablet the
|
||
// on-screen keyboard stays up over the faces the user
|
||
// pressed Enter to get back to — the name looks accepted
|
||
// and the screen looks stuck.
|
||
accepted(t) => {
|
||
root.rename(t);
|
||
// To the screen's keys rather than to nothing, so
|
||
// the arrows walk the rail again straight away.
|
||
screen-keys.focus();
|
||
}
|
||
}
|
||
if root.selected-person < 0: Text {
|
||
text: "Select a person";
|
||
color: Theme.ink-dim;
|
||
font-size: Theme.text-lg;
|
||
vertical-alignment: center;
|
||
}
|
||
|
||
Rectangle { }
|
||
}
|
||
|
||
// The actions, in a strip that scrolls rather than overflowing.
|
||
//
|
||
// A Flickable's own minimum is nothing — it is built to be smaller
|
||
// than what it holds — so wrapping the row stops it inflating the
|
||
// screen and makes the buttons past the edge reachable instead of
|
||
// merely absent. Same device as the library's filter chips, for
|
||
// the same reason.
|
||
Flickable {
|
||
// A control's height, not a guess, and not read back from the
|
||
// row inside — `actions` sizes itself from `viewport-height`,
|
||
// so measuring it here would be a binding loop.
|
||
height: Theme.control-height;
|
||
viewport-height: self.height;
|
||
viewport-width: max(self.width, actions.preferred-width);
|
||
|
||
actions := HorizontalLayout {
|
||
width: parent.viewport-width;
|
||
height: parent.viewport-height;
|
||
spacing: Theme.gap-sm;
|
||
alignment: start;
|
||
|
||
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: root.coverage-read-only ? "Read eye state" : "Index faces";
|
||
clicked => { root.index-faces(); }
|
||
}
|
||
if root.indexing: Button {
|
||
text: "Stop";
|
||
clicked => { root.stop-indexing(); }
|
||
}
|
||
// The way back from a face to the photographs. This is the
|
||
// point of having identified anybody, and without it the
|
||
// screen is a filing cabinet with no drawer handles.
|
||
// GESTURE: See a person's photographs
|
||
// where: People
|
||
// touch: Choose them in the rail, then "Show photos"
|
||
// pointer: Choose them in the rail, then "Show photos"
|
||
// why: This is the point of having identified anybody.
|
||
// Without it the screen is a filing cabinet with no
|
||
// drawer handles.
|
||
// manual: people
|
||
if root.selected-person >= 0: Button {
|
||
text: "Show photos";
|
||
clicked => { root.show-photos(root.selected-person, false); }
|
||
}
|
||
// Builds the union or intersection a person at a time. Only
|
||
// offered once the grid is narrowed to somebody, because
|
||
// "and also" with nothing to add to is just "Show photos".
|
||
if root.selected-person >= 0 && root.photos-filtered: Button {
|
||
text: "And also…";
|
||
clicked => { root.show-photos(root.selected-person, true); }
|
||
}
|
||
// Most clusters in a real library are strangers — passers-by,
|
||
// other people's guests, a face on a poster. Naming them is
|
||
// not the job and neither is looking at them again.
|
||
if root.selected-person >= 0: Button {
|
||
text: root.selected-ignored ? "Bring back" : "Not interested";
|
||
clicked => {
|
||
root.ignore-person(root.selected-person, !root.selected-ignored);
|
||
}
|
||
}
|
||
Button {
|
||
text: root.regrouping ? "Regrouping…" : "Regroup";
|
||
enabled: !root.regrouping;
|
||
clicked => { root.recluster(); }
|
||
}
|
||
// The dials Regroup turns, immediately beside it. They belong
|
||
// here and not on the settings page: they are only meaningful
|
||
// next to the button that applies them and the rail that shows
|
||
// what they did, and a value changed three screens away from
|
||
// its effect is a value nobody can tune.
|
||
// GESTURE: Change how faces are grouped
|
||
// where: People
|
||
// touch: "Grouping…", move the dials, then Regroup
|
||
// pointer: "Grouping…", move the dials, then Regroup
|
||
// why: The right match confidence is a property of your
|
||
// library, not of the model. "What would this do?"
|
||
// answers for this library without writing
|
||
// anything; names, confirmations and the groups you
|
||
// have set aside are kept whatever the dials say.
|
||
// manual: people
|
||
Button {
|
||
text: "Grouping…";
|
||
active: root.grouping-open;
|
||
clicked => { root.grouping-open = !root.grouping-open; }
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- the grouping dials ---------------------------------------
|
||
//
|
||
// Opened inline rather than in a popup, the way the develop
|
||
// column's film picker is: this screen already scrolls as one, and
|
||
// a second overlay to dismiss is a second gesture to lose.
|
||
if root.grouping-open: Rectangle {
|
||
border-radius: Theme.radius;
|
||
background: Theme.surface-raised;
|
||
// Stated, because the layout above it is a VerticalLayout that
|
||
// would otherwise stretch this block over the faces grid.
|
||
height: dials.preferred-height + 2 * Theme.gap;
|
||
|
||
dials := VerticalLayout {
|
||
x: Theme.gap;
|
||
y: Theme.gap;
|
||
width: parent.width - 2 * Theme.gap;
|
||
spacing: Theme.gap-sm;
|
||
|
||
// **The hints are two words each, and that is deliberate.**
|
||
// `FieldRow` draws a hint as a non-wrapping elided
|
||
// `Caption`, whose *minimum* width is still its whole text
|
||
// — and a layout cannot be narrower than its children's
|
||
// minimums, so a sentence here would set the minimum width
|
||
// of this pane and, on a phone, of the screen. The
|
||
// sentences go in the wrapping paragraph below instead,
|
||
// where they cost nothing.
|
||
SliderRow {
|
||
label: "Match confidence";
|
||
hint: "%";
|
||
value: root.merge-probability;
|
||
default-value: 80;
|
||
minimum: 50;
|
||
maximum: 95;
|
||
precision: 0;
|
||
enabled: !root.regrouping;
|
||
changed(v) => { root.merge-probability-changed(v); }
|
||
reset => { root.merge-probability-changed(80); }
|
||
}
|
||
|
||
SliderRow {
|
||
label: "Smallest group";
|
||
hint: "faces";
|
||
value: root.min-group-size;
|
||
default-value: 2;
|
||
minimum: 1;
|
||
maximum: 12;
|
||
precision: 0;
|
||
enabled: !root.regrouping;
|
||
changed(v) => { root.min-group-size-changed(v); }
|
||
reset => { root.min-group-size-changed(2); }
|
||
}
|
||
|
||
// The dial is otherwise blind: nothing on the screen says
|
||
// what 78% means for *this* library until the user commits
|
||
// to a pass and reads the rail. `dr_face`'s tuning table is
|
||
// the right answer to that question and it is measured on
|
||
// somebody else's photographs, so this runs it here — one
|
||
// row of it, read-only, for the value actually set.
|
||
HorizontalLayout {
|
||
spacing: Theme.gap-sm;
|
||
alignment: start;
|
||
|
||
Button {
|
||
text: root.previewing ? "Working…" : "What would this do?";
|
||
enabled: !root.previewing && !root.regrouping;
|
||
clicked => { root.preview-grouping(); }
|
||
}
|
||
if root.grouping-preview != "": Text {
|
||
text: root.grouping-preview;
|
||
color: Theme.ink-dim;
|
||
font-size: Theme.text-sm;
|
||
vertical-alignment: center;
|
||
wrap: word-wrap;
|
||
horizontal-stretch: 1;
|
||
}
|
||
}
|
||
|
||
// What the two dials mean, and — said plainly, because
|
||
// they look destructive and are not — what they cannot
|
||
// touch: they move the *suggested* half, which this pass
|
||
// owns and may revise as often as it likes.
|
||
Text {
|
||
text: "Lower confidence gathers more of each person together; too low and different people are welded into one. A bigger smallest group keeps one-off strangers off the rail.\n\nPress Regroup to apply. Names, confirmations and the groups you have set aside are kept whatever the dials say.";
|
||
color: Theme.ink-faint;
|
||
font-size: Theme.text-sm;
|
||
wrap: word-wrap;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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: "The chosen face detector is not 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";
|
||
label: "Recheck coverage";
|
||
clicked => { root.check-coverage(); }
|
||
}
|
||
}
|
||
|
||
// FR-CULL-9, made visible: the percentages are real numbers off a
|
||
// real curve, but on a young library that curve is the built-in one
|
||
// rather than one measured here. Said once, above the grid, so no
|
||
// per-face percentage implies a measurement that was never made.
|
||
if !root.calibrated && !root.model-missing: Text {
|
||
text: "Confidences use the built-in similarity curve; they will sharpen once this library has enough confirmed faces to fit its own.";
|
||
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;
|
||
}
|
||
|
||
if root.regroup-status != "": Text {
|
||
text: root.regroup-status;
|
||
color: Theme.ink-dim;
|
||
font-size: Theme.text-sm;
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
// --- the faces, as a grid ------------------------------------
|
||
//
|
||
// This was one `HorizontalLayout` holding every face. The comment
|
||
// on it claimed to be a wrapping row; Slint has no flow layout and
|
||
// a HorizontalLayout does not wrap, so what it actually drew was a
|
||
// single row running off the right-hand edge with everything past
|
||
// the fourth or fifth face unreachable. A person with forty faces
|
||
// was a person whose faces could not be reviewed.
|
||
//
|
||
// Laid out the way the library grid lays out thumbnails, for the
|
||
// same reasons and with the same arithmetic: choose how many
|
||
// columns of roughly the requested size fit, then divide the width
|
||
// between them so the cells fill the row exactly and nothing
|
||
// overhangs. Cells are placed absolutely inside the Flickable,
|
||
// which is what makes the wrap possible at all.
|
||
faces-area := Flickable {
|
||
/// About this big. A request, not a measurement — `cell` is
|
||
/// what is actually drawn.
|
||
property <length> requested: root.compact ? 96px : 128px;
|
||
|
||
/// Rounded rather than floored, so a width nine tenths of the
|
||
/// way to another column takes it instead of stranding it.
|
||
property <int> columns:
|
||
max(1, round((self.width - Theme.gap)
|
||
/ (self.requested + Theme.gap)));
|
||
|
||
/// The cells share the width exactly: `columns` of them and
|
||
/// the `columns + 1` gaps around them come to the full width,
|
||
/// so there is no remainder left as a dead strip down the
|
||
/// edge — which on a phone is a quarter of the screen.
|
||
property <length> cell:
|
||
max(64px, (self.width - Theme.gap * (self.columns + 1)) / self.columns);
|
||
|
||
/// The pitch between rows. The caption under each crop is part
|
||
/// of the cell, so it is part of the pitch.
|
||
property <length> row-pitch: self.cell + 34px + Theme.gap;
|
||
property <int> rows: ceil(root.faces.length / max(1, self.columns));
|
||
|
||
// Horizontal extent is exactly the viewport: the grid wraps,
|
||
// so there is nothing to scroll sideways to.
|
||
viewport-width: self.width;
|
||
viewport-height: Theme.gap + self.rows * self.row-pitch;
|
||
|
||
for f[i] in root.faces: FaceCell {
|
||
x: Theme.gap + mod(i, faces-area.columns) * (faces-area.cell + Theme.gap);
|
||
y: Theme.gap + floor(i / faces-area.columns) * faces-area.row-pitch;
|
||
edge: faces-area.cell;
|
||
face: f;
|
||
confirm => { root.confirm-face(f.id); }
|
||
reject => { root.reject-face(f.id); }
|
||
toggle-pick => { root.toggle-pick(f.id); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|