"Show photos" could only ever mean one person. The two questions a photographer actually asks are "every picture of Anna or Bob" and "the pictures they are both in", and the second is not reachable by any sequence of single-person filters — no amount of switching between one person and another finds the frame they share. So the filter holds a *set* of people and a mode. `RatingFilter` was already the right home, as its own doc says: every query path threads it, so the count in the header and the cells in the grid are narrowed by the same thing, and this composes with stars, flags and the date range for free. The union is `EXISTS ... person_id IN (...)`. The intersection counts **distinct** people per image and compares against the size of the selection — one subquery rather than one per person, and it does not grow the statement with the selection. `DISTINCT` is what makes it correct: three faces of Anna in one frame must not satisfy a filter asking for Anna and Bob, and there is a test that says so. Any rather than All is the default. With one person the modes are the same filter, and adding a second to a union can only ever show more — so a user who has not noticed the toggle never ends up staring at an empty grid wondering what they broke. The toggle only appears at two people, because a control that demonstrably does nothing is a control that teaches the user to ignore it. Building the set needs no picker of its own: the Identity screen gains "And also…" beside "Show photos", offered only once the grid is already narrowed to somebody. Each person is a chip on the filter bar and each chip removes just that person, so a selection of three can be taken apart one at a time rather than only cleared wholesale. `RatingFilter` stops being `Copy`, since it now holds a `Vec`. Every query path already took it by reference; the casualties were two struct updates and one `Cell` that becomes a `RefCell`. 484 dr-ui tests pass, including the union, the intersection, that one person reads the same in both modes, and the repeated-faces trap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
662 lines
30 KiB
Plaintext
662 lines
30 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 } 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,
|
||
// 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", "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();
|
||
|
||
/// 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;
|
||
}
|
||
|
||
// 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;
|
||
/// 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 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;
|
||
/// 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;
|
||
/// 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();
|
||
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;
|
||
|
||
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 and visibility rather than filtering the
|
||
// model in Rust: the rail is rebuilt on every
|
||
// action, and re-deriving a second filtered list
|
||
// per rebuild is work the toggle can do for free.
|
||
visible: root.show-ignored || !p.ignored;
|
||
height: (root.show-ignored || !p.ignored) ? 52px : 0px;
|
||
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 { }
|
||
|
||
// 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.
|
||
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";
|
||
// 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); }
|
||
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 { }
|
||
}
|
||
|
||
// 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 {
|
||
height: 36px;
|
||
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: "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.
|
||
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 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;
|
||
}
|
||
|
||
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); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|