// 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 { 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, // 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 face; in property 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 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 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; } } // 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. 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 compact: false; in property selected-person: -1; in property 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 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 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 calibrated: false; in property indexing: false; in property 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 coverage; in property coverage-complete: false; // No model on disk: face indexing cannot run at all (docs/faces.md §2.2). in property model-missing: false; in property 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 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 merge-offer-name; in property 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 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 regrouping: false; in property 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 grouping-open: false; /// People the user has set aside, and whether the rail is showing them. in property ignored-count: 0; in property 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 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 merge-probability: 80; /// The smallest group the pass will make a person out of. in property 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 previewing: false; /// The answer, in words. Empty until one has been asked for. in property 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; 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); } // 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: "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: "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 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 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 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 row-pitch: self.cell + 34px + Theme.gap; property 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); } } } } } }