Keep the name typed into a cluster when the next one is opened
Naming a cluster and moving straight to the next is the gesture this screen
exists for, and it discarded the name every time. Two causes, both in the same
four lines.
`Field.text` is two-way bound to its `TextInput`. Binding it to `selected-name`
therefore works exactly once: the first keystroke writes through the `<=>` and
**replaces** the declarative binding, after which the field follows nothing.
Switching clusters left the previous cluster's half-typed text on screen,
attached to the new person.
And `Field` only reported `accepted`, which is Enter. A name typed and then
abandoned by clicking the next face never reached Rust at all.
So `Field` gains an `edited` callback, the screen keeps the draft with the
person it was typed for, and the draft is written when the selection moves or
the screen closes. The field is then reset from a revision counter the screen
watches.
A counter rather than `changed selected-name`, 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. Nor `changed
selected-person`, since accepting a namesake merge lands the user back on a
person they may already have been on.
The draft carries its `PersonId`. A reload can move the selection out from under
a half-typed name — a merge arriving through a sync, a deletion — and applying
it to whoever is selected now would rename a stranger. If the person is gone
when the draft lands, it is dropped rather than resurrecting a row the rail no
longer shows.
`None` and `Some("")` are kept distinct. A user who cleared the field means to
clear the name; a user who never touched it means to leave it alone. Collapsing
those two erases names by walking past them.
An implicit commit does not raise the namesake merge offer. That question is
about a screen the user has already left, and answering it on their behalf while
they look at the next cluster is not a question at all — the offer stays on the
explicit submit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -567,6 +567,8 @@ export component AppWindow inherits Window {
|
||||
/// The namesake merge the Identity screen is offering. See IdentityScreen.
|
||||
in property <string> identity-merge-offer-name;
|
||||
in property <int> identity-merge-offer-faces: 0;
|
||||
/// See IdentityScreen.name-revision.
|
||||
in property <int> identity-name-revision: 0;
|
||||
|
||||
callback identity-open();
|
||||
callback identity-close();
|
||||
@@ -574,6 +576,7 @@ export component AppWindow inherits Window {
|
||||
callback identity-rename(string);
|
||||
callback identity-merge-accept();
|
||||
callback identity-merge-decline();
|
||||
callback identity-name-edited(string);
|
||||
callback identity-confirm-face(int);
|
||||
callback identity-reject-face(int);
|
||||
callback identity-toggle-pick(int);
|
||||
@@ -1377,10 +1380,12 @@ in property <bool> panel-visible: true;
|
||||
coverage-complete: root.identity-coverage-complete;
|
||||
merge-offer-name: root.identity-merge-offer-name;
|
||||
merge-offer-faces: root.identity-merge-offer-faces;
|
||||
name-revision: root.identity-name-revision;
|
||||
|
||||
person-picked(id) => { root.identity-person-picked(id); }
|
||||
rename(name) => { root.identity-rename(name); }
|
||||
merge-accept() => { root.identity-merge-accept(); }
|
||||
name-edited(t) => { root.identity-name-edited(t); }
|
||||
merge-decline() => { root.identity-merge-decline(); }
|
||||
confirm-face(id) => { root.identity-confirm-face(id); }
|
||||
reject-face(id) => { root.identity-reject-face(id); }
|
||||
|
||||
@@ -193,6 +193,19 @@ export component IdentityScreen inherits Rectangle {
|
||||
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;
|
||||
callback recluster();
|
||||
callback index-faces();
|
||||
callback stop-indexing();
|
||||
@@ -200,6 +213,15 @@ export component IdentityScreen inherits Rectangle {
|
||||
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 {
|
||||
@@ -315,10 +337,16 @@ export component IdentityScreen inherits Rectangle {
|
||||
// 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.
|
||||
if root.selected-person >= 0: Field {
|
||||
// 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";
|
||||
width: 240px;
|
||||
width: root.selected-person >= 0 ? 240px : 0px;
|
||||
visible: root.selected-person >= 0;
|
||||
edited(t) => { root.name-edited(t); }
|
||||
accepted(t) => { root.rename(t); }
|
||||
}
|
||||
if root.selected-person < 0: Text {
|
||||
|
||||
@@ -606,6 +606,15 @@ export component Field inherits Rectangle {
|
||||
out property <bool> has-focus: input.has-focus;
|
||||
|
||||
callback accepted(string);
|
||||
/// Every keystroke, not just Enter.
|
||||
///
|
||||
/// `text` is two-way bound to the entry below, which means the first
|
||||
/// keystroke **replaces** whatever declarative binding a caller put on it.
|
||||
/// A caller that binds `text` to a selection and expects it to follow that
|
||||
/// selection afterwards is therefore wrong, and silently so. This callback
|
||||
/// is how a caller keeps the draft instead — and `Theme` cannot help it,
|
||||
/// because the breakage is in Slint's binding model, not the styling.
|
||||
callback edited(string);
|
||||
|
||||
height: Theme.touch-target;
|
||||
border-radius: Theme.radius;
|
||||
@@ -615,6 +624,7 @@ export component Field inherits Rectangle {
|
||||
|
||||
input := TextInput {
|
||||
text <=> root.text;
|
||||
edited => { root.edited(self.text); }
|
||||
color: Theme.ink;
|
||||
font-size: Theme.text;
|
||||
vertical-alignment: center;
|
||||
|
||||
Reference in New Issue
Block a user