The Identity header was two rows pinned to 32px and 36px. Neither number was big enough for what the row held: a `Field` is `Theme.touch-target` — 44px — and a `Button` is `Theme.control-height`. Slint honours a child's own height and lets it overflow the box the layout gave it, so the name field drew 44px from the top of a 32px row while the button strip began at 38px. The overlap was 6px of text box sitting on top of "Confirm all". Neither row states a height any more. The first takes the height of what is in it, and the strip takes the height of a control — read from the theme rather than from the row inside it, since `actions` sizes itself from the Flickable's viewport and measuring it back would be a binding loop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
673 lines
30 KiB
Plaintext
673 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.
|
||
//
|
||
// **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: 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); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|