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:
@@ -57,6 +57,17 @@ pub struct IdentityController {
|
||||
/// a row it would not, and the button would be the only sign it was
|
||||
/// running — invisible from every other screen.
|
||||
activity: RefCell<Option<crate::activity::Activity>>,
|
||||
/// The name being typed, and who it is being typed for.
|
||||
///
|
||||
/// `None` means the field has not been touched, which is **not** the same
|
||||
/// as an empty draft: a user who cleared the field means to clear the name,
|
||||
/// and a user who never typed means to leave it alone. Collapsing those two
|
||||
/// would erase a name by navigating past it.
|
||||
///
|
||||
/// Carries the person because a reload can move the selection underneath a
|
||||
/// half-typed name — a merge from the other side of a sync, a deletion —
|
||||
/// and applying it to whoever is selected *now* would rename a stranger.
|
||||
draft: RefCell<Option<(PersonId, String)>>,
|
||||
/// The person a namesake merge is currently being offered against.
|
||||
///
|
||||
/// Held here rather than read back off the window because the window
|
||||
@@ -226,6 +237,59 @@ pub fn refresh_coverage(
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a name that was typed but never submitted.
|
||||
///
|
||||
/// The screen's primary action is naming a cluster, and its primary *gesture*
|
||||
/// is naming one and moving straight to the next — which is not the gesture
|
||||
/// `accepted` reports, because that one requires Enter. Without this, the
|
||||
/// common case silently discards the work.
|
||||
///
|
||||
/// Nothing is offered here. A namesake merge is a question, and asking it about
|
||||
/// the person the user has just navigated away from would be answering for a
|
||||
/// screen they are no longer looking at; the offer stays on the explicit
|
||||
/// submit.
|
||||
fn commit_draft(ctl: &IdentityController, catalog: &Rc<RefCell<Option<Catalog>>>) {
|
||||
let Some((person, draft)) = ctl.draft.borrow_mut().take() else {
|
||||
return;
|
||||
};
|
||||
// Unchanged text is not an edit. Comparing against what the catalog holds
|
||||
// rather than tracking dirtiness keeps this correct when a reload has
|
||||
// already written the same name.
|
||||
let stored = ctl
|
||||
.people
|
||||
.borrow()
|
||||
.iter()
|
||||
.find(|p| p.id == person)
|
||||
.map(|p| p.name.clone());
|
||||
let Some(stored) = stored else {
|
||||
// The person went away while the name was being typed. Writing it
|
||||
// would resurrect a row the rail no longer shows.
|
||||
return;
|
||||
};
|
||||
if draft.trim() == stored {
|
||||
return;
|
||||
}
|
||||
let borrow = catalog.borrow();
|
||||
let Some(cat) = borrow.as_ref() else { return };
|
||||
if let Err(e) = identity::rename(cat, person, &draft) {
|
||||
log::warn!("identity: committing a typed name: {e}");
|
||||
} else {
|
||||
log::info!(
|
||||
"identity: {person:?} named {:?} on leaving it",
|
||||
draft.trim()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the newly-selected person's name back in the field.
|
||||
///
|
||||
/// A counter the screen watches, because `Field.text` is two-way bound to its
|
||||
/// entry: the first keystroke replaces the binding to `selected-name`, and from
|
||||
/// then on the field follows nothing at all.
|
||||
fn reset_name_field(window: &AppWindow) {
|
||||
window.set_identity_name_revision(window.get_identity_name_revision().wrapping_add(1));
|
||||
}
|
||||
|
||||
/// Take the namesake offer off the screen.
|
||||
///
|
||||
/// The name is what the strip keys on being visible, so emptying it is what
|
||||
@@ -331,8 +395,13 @@ pub fn wire<S, M, P>(
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let catalog = catalog.clone();
|
||||
window.on_identity_close(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
// The other way out of a half-typed name: leaving the screen
|
||||
// entirely. Same rule as changing person — the work was done, so it
|
||||
// is written.
|
||||
commit_draft(&ctl, &catalog);
|
||||
// Back to whichever screen this was opened from. The develop
|
||||
// session was never torn down — it was only hidden — so returning
|
||||
// to it costs nothing and keeps the photographer's place.
|
||||
@@ -348,12 +417,17 @@ pub fn wire<S, M, P>(
|
||||
let store = store.clone();
|
||||
window.on_identity_person_picked(move |id| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
// Before the selection moves: a name typed into the field and never
|
||||
// submitted belongs to the person being left, and this is the last
|
||||
// moment it can be written.
|
||||
commit_draft(&ctl, &catalog);
|
||||
ctl.selected.set(Some(PersonId(id as u64)));
|
||||
ctl.clear_picks();
|
||||
// The offer was about the person being navigated away from. Left
|
||||
// up, its "Merge" would fold whoever is selected *now*.
|
||||
clear_merge_offer(&w, &ctl);
|
||||
reload!(w, ctl, catalog, store);
|
||||
reset_name_field(&w);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -371,6 +445,9 @@ pub fn wire<S, M, P>(
|
||||
// afterwards, and answering "keep separate" must leave the name the
|
||||
// user typed exactly where they typed it.
|
||||
clear_merge_offer(&w, &ctl);
|
||||
// Submitted explicitly, so the pending draft is spent — leaving it
|
||||
// would rewrite this same name on the way out of the person.
|
||||
ctl.draft.borrow_mut().take();
|
||||
if let Some(cat) = catalog.borrow().as_ref() {
|
||||
if let Err(e) = identity::rename(cat, person, &name) {
|
||||
log::warn!("identity: rename: {e}");
|
||||
@@ -394,6 +471,9 @@ pub fn wire<S, M, P>(
|
||||
}
|
||||
}
|
||||
reload!(w, ctl, catalog, store);
|
||||
// `rename` trims; the field should show what was actually stored
|
||||
// rather than the spacing the user happened to type.
|
||||
reset_name_field(&w);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -427,6 +507,19 @@ pub fn wire<S, M, P>(
|
||||
}
|
||||
clear_merge_offer(&w, &ctl);
|
||||
reload!(w, ctl, catalog, store);
|
||||
reset_name_field(&w);
|
||||
});
|
||||
}
|
||||
|
||||
// Every keystroke in the name field. Kept rather than written, because a
|
||||
// rename per character would be a revision per character in the sidecar.
|
||||
{
|
||||
let ctl = ctl.clone();
|
||||
window.on_identity_name_edited(move |text| {
|
||||
let Some(person) = ctl.selected.get() else {
|
||||
return;
|
||||
};
|
||||
*ctl.draft.borrow_mut() = Some((person, text.to_string()));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user