FR-CULL-9 was read as "no fit, no number", so every library without 200 confirmed positive pairs showed "Confidence unavailable" on every suggestion — which is every library, until enough confirmations exist to fit one. The confirmations are made on this screen, ranked by the number it was withholding, so the degraded state was also the permanent one. There has always been a curve: Calibration::default is the reference implementation's fitted MBF sigmoid, which is what clustering already operates at. It is a published operating point, not an invention, and what the requirement forbids is presenting it *as though it were measured on this library*. So the percentage is shown, and the screen says once, above the grid, where the curve came from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
675 lines
31 KiB
Plaintext
675 lines
31 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" 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,
|
||
// 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 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;
|
||
// 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.
|
||
//
|
||
// **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); }
|
||
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 {
|
||
// 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: "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: 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); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|