docs/ had 26 developer documents flat beside the manual, and the two audiences are very differently sized: most readers want the manual and the gesture reference, a few want the register, the designs and the measurements. The manual and gestures.md stay at the top; everything for someone changing the code moves to docs/dev/, and the two documents that name their own successors — the v0.1 milestone and the UI-refinement plan — go to docs/dev/archive/ rather than being deleted, since both are still cited. docs/README.md is the index, users first. Every reference follows: code comments, Cargo manifests, the workflows, the pre-commit hook, the bench and traceability tools (which locate the repo root by docs/dev/requirements.md now), packaging, the Docker READMEs, CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level deeper and is regenerated. Links out of the moved documents into the tree gain a level; a link checker over every Markdown file finds none broken.
928 lines
44 KiB
Plaintext
928 lines
44 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 { 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.
|
||
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.
|
||
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);
|
||
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;
|
||
}
|
||
|
||
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);
|
||
name-field.release-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.
|
||
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.
|
||
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); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|