Ask for the collection's name where the keyboard can reach it
"New collection from selection" created the collection under a placeholder name and then opened the rename field in the sidebar tree. On a tablet the sidebar is not on screen. It is instantiated all the same — app.slint collapses it to zero width and `visible: false` rather than using an `if`, because an `if` there is a layout loop Slint panics on — so the rename field was created, its `init` took focus, and Android raised the on-screen keyboard over a box nobody could see. Nothing else on the screen is focusable, so the keyboard had nowhere to go: it stayed, the name could not be typed, and the collection was already written under the name the user did not want. Asked in a sheet instead, on the same card as the filing and keywording sheets, before anything is written. That also fixes what was hiding behind it: an abandoned rename used to leave a "New collection" in the tree, because the collection existed before the name did. `Field` gains `take-focus()` so a sheet whose field is the only thing to do in it can answer the keyboard for the user — a function rather than a property, because focus is an event and a bound property would re-take it on every unrelated re-evaluation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+15
-15
File diff suppressed because one or more lines are too long
@@ -1586,13 +1586,20 @@ pub fn wire<S, R, P, C>(
|
|||||||
// the tree's "+". A selection can be gathered from anywhere, including
|
// the tree's "+". A selection can be gathered from anywhere, including
|
||||||
// across collections, so filing it under whichever one happens to be open
|
// across collections, so filing it under whichever one happens to be open
|
||||||
// would put it somewhere its contents did not come from.
|
// would put it somewhere its contents did not come from.
|
||||||
|
//
|
||||||
|
// The name arrives already chosen. It used to be a placeholder, with the
|
||||||
|
// sidebar's rename field opened straight afterwards to correct it — which
|
||||||
|
// on a tablet meant opening a field inside a panel that is instantiated but
|
||||||
|
// not drawn, so it took the on-screen keyboard and could never give it
|
||||||
|
// back. `library.slint`'s naming sheet asks first, and nothing reaches the
|
||||||
|
// catalog until it is answered.
|
||||||
{
|
{
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
let ctl = ctl.clone();
|
let ctl = ctl.clone();
|
||||||
let catalog = catalog.clone();
|
let catalog = catalog.clone();
|
||||||
let visible = visible_ids.clone();
|
let visible = visible_ids.clone();
|
||||||
let reload = on_scope_changed.clone();
|
let reload = on_scope_changed.clone();
|
||||||
window.on_library_collection_from_selection(move || {
|
window.on_library_collection_from_selection(move |name| {
|
||||||
let Some(w) = weak.upgrade() else { return };
|
let Some(w) = weak.upgrade() else { return };
|
||||||
let images = ctl.selected();
|
let images = ctl.selected();
|
||||||
if images.is_empty() {
|
if images.is_empty() {
|
||||||
@@ -1605,12 +1612,24 @@ pub fn wire<S, R, P, C>(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let name = unique_name(cat.connection(), None);
|
// The sheet refuses an empty name in two places, so this is
|
||||||
|
// belt-and-braces rather than a path the UI can reach — but a
|
||||||
|
// collection with no name at all is unfindable in the tree, and
|
||||||
|
// falling back to the placeholder is recoverable where an empty
|
||||||
|
// row is not.
|
||||||
|
let name = match name.trim() {
|
||||||
|
"" => unique_name(cat.connection(), None),
|
||||||
|
typed => typed.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// The id is not carried out. It used to be, to open the rename
|
||||||
|
// field on the row it names; now the name is already right, and
|
||||||
|
// `refresh_tree` redraws the tree from the catalog either way.
|
||||||
let made = coll::create(cat.connection(), &name, None, CollectionKind::Manual)
|
let made = coll::create(cat.connection(), &name, None, CollectionKind::Manual)
|
||||||
.and_then(|id| coll::add_images(cat.connection(), id, &images).map(|n| (id, n)));
|
.and_then(|id| coll::add_images(cat.connection(), id, &images));
|
||||||
|
|
||||||
match made {
|
match made {
|
||||||
Ok((id, added)) => {
|
Ok(added) => {
|
||||||
w.set_collection_error(slint::SharedString::new());
|
w.set_collection_error(slint::SharedString::new());
|
||||||
w.set_library_status(
|
w.set_library_status(
|
||||||
format!(
|
format!(
|
||||||
@@ -1628,11 +1647,10 @@ pub fn wire<S, R, P, C>(
|
|||||||
ctl.clear_selection();
|
ctl.clear_selection();
|
||||||
sync_selection(&w, &ctl, &visible());
|
sync_selection(&w, &ctl, &visible());
|
||||||
|
|
||||||
// Into the name field, for the same reason `collection_new`
|
// No rename opened here. The name was chosen before the
|
||||||
// does it: "New collection" is a placeholder nobody wants
|
// collection existed, so there is nothing left to correct —
|
||||||
// to keep, and making them find the rename afterwards is
|
// and opening the sidebar's field is what stranded the
|
||||||
// asking them to finish a job we started.
|
// keyboard on a tablet.
|
||||||
w.set_collection_renaming(id.0 as i32);
|
|
||||||
reload();
|
reload();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ export component AppWindow inherits Window {
|
|||||||
callback library-toggle-date-range();
|
callback library-toggle-date-range();
|
||||||
/// TRACES: FR-CAT-5
|
/// TRACES: FR-CAT-5
|
||||||
callback library-clear-selection();
|
callback library-clear-selection();
|
||||||
callback library-collection-from-selection();
|
callback library-collection-from-selection(string);
|
||||||
/// TRACES: FR-CAT-6
|
/// TRACES: FR-CAT-6
|
||||||
in property <string> library-range-from;
|
in property <string> library-range-from;
|
||||||
in property <string> library-range-to;
|
in property <string> library-range-to;
|
||||||
@@ -1440,7 +1440,9 @@ in property <bool> panel-visible: true;
|
|||||||
range-active: root.library-range-active;
|
range-active: root.library-range-active;
|
||||||
toggle-date-range() => { root.library-toggle-date-range(); }
|
toggle-date-range() => { root.library-toggle-date-range(); }
|
||||||
clear-selection() => { root.library-clear-selection(); }
|
clear-selection() => { root.library-clear-selection(); }
|
||||||
collection-from-selection() => { root.library-collection-from-selection(); }
|
collection-from-selection(name) => {
|
||||||
|
root.library-collection-from-selection(name);
|
||||||
|
}
|
||||||
range-from: root.library-range-from;
|
range-from: root.library-range-from;
|
||||||
range-to: root.library-range-to;
|
range-to: root.library-range-to;
|
||||||
range-invalid: root.library-range-invalid;
|
range-invalid: root.library-range-invalid;
|
||||||
|
|||||||
+126
-4
@@ -1243,8 +1243,10 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
/// Drop the selection without leaving select mode.
|
/// Drop the selection without leaving select mode.
|
||||||
callback clear-selection();
|
callback clear-selection();
|
||||||
/// TRACES: FR-CAT-5
|
/// TRACES: FR-CAT-5
|
||||||
/// Make a new collection holding exactly what is selected.
|
/// Make a new collection, under the given name, holding exactly what is
|
||||||
callback collection-from-selection();
|
/// selected. The name arrives from the sheet below rather than being
|
||||||
|
/// invented by Rust and corrected afterwards — see `naming`.
|
||||||
|
callback collection-from-selection(string);
|
||||||
/// The drag payload: the selected image ids, wrapped by Rust. Called when a
|
/// The drag payload: the selected image ids, wrapped by Rust. Called when a
|
||||||
/// drag starts, so it always reflects the selection as it is at that moment.
|
/// drag starts, so it always reflects the selection as it is at that moment.
|
||||||
pure callback drag-payload() -> data-transfer;
|
pure callback drag-payload() -> data-transfer;
|
||||||
@@ -1443,6 +1445,26 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
/// disclosure rather than a preference, and what closes it is dismissing it.
|
/// disclosure rather than a preference, and what closes it is dismissing it.
|
||||||
property <bool> keywording: false;
|
property <bool> keywording: false;
|
||||||
|
|
||||||
|
// --- naming a new collection (FR-CAT-5, FR-CAT-7) -----------------------
|
||||||
|
//
|
||||||
|
// "New collection from selection" used to create the collection under a
|
||||||
|
// placeholder name and then open the rename field in the sidebar tree.
|
||||||
|
//
|
||||||
|
// On a tablet the sidebar is not on screen. It is instantiated all the
|
||||||
|
// same — `app.slint` collapses it to zero width and `visible: false`
|
||||||
|
// rather than using an `if`, because an `if` there is a layout loop Slint
|
||||||
|
// panics on — so the rename field was created, its `init` took focus, and
|
||||||
|
// Android raised the on-screen keyboard for a box nobody could see. Nothing
|
||||||
|
// else on the screen is focusable, so the keyboard had nowhere to go: it
|
||||||
|
// stayed, the name could not be typed, and the collection was already
|
||||||
|
// written under the name the user did not want.
|
||||||
|
//
|
||||||
|
// Asked here instead, before anything is written. A sheet dismissed leaves
|
||||||
|
// no collection behind, which the create-then-rename order could not
|
||||||
|
// promise.
|
||||||
|
/// Whether the naming sheet is up. Local, like `filing` and `keywording`.
|
||||||
|
property <bool> naming: false;
|
||||||
|
|
||||||
// Cell geometry. Columns are derived from the available width so the grid
|
// Cell geometry. Columns are derived from the available width so the grid
|
||||||
// reflows with the window rather than fixing a count (FR-UI-1).
|
// reflows with the window rather than fixing a count (FR-UI-1).
|
||||||
// Zoomable, so the grid serves both jobs: fewer, larger images for
|
// Zoomable, so the grid serves both jobs: fewer, larger images for
|
||||||
@@ -2081,11 +2103,15 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
// took four steps: make a collection, find it, select the
|
// took four steps: make a collection, find it, select the
|
||||||
// photographs again, add them. One press instead, which is how
|
// photographs again, add them. One press instead, which is how
|
||||||
// a selection is usually meant.
|
// a selection is usually meant.
|
||||||
|
// No longer "…from selection": the sheet it opens says
|
||||||
|
// "New collection holding 12 photographs" at the top, so a
|
||||||
|
// button repeating it is width the strip does not have on a
|
||||||
|
// tablet in portrait.
|
||||||
Button {
|
Button {
|
||||||
text: "New collection from selection";
|
text: "New collection";
|
||||||
primary: true;
|
primary: true;
|
||||||
y: (parent.height - self.height) / 2;
|
y: (parent.height - self.height) / 2;
|
||||||
clicked => { root.collection-from-selection(); }
|
clicked => { root.naming = true; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3259,4 +3285,100 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- the naming sheet (FR-CAT-5, FR-CAT-7) ------------------------------
|
||||||
|
//
|
||||||
|
// Why a sheet at all, rather than the sidebar's rename field: see `naming`
|
||||||
|
// above. The short version is that on a tablet the sidebar is instantiated
|
||||||
|
// but not drawn, so the rename field could take the keyboard without ever
|
||||||
|
// being visible.
|
||||||
|
//
|
||||||
|
// The same card, scrim and dismissal as the two sheets above it, for the
|
||||||
|
// same reason they share one: a user who has filed a selection knows how
|
||||||
|
// this works.
|
||||||
|
if root.naming: Rectangle {
|
||||||
|
background: #000000CC;
|
||||||
|
|
||||||
|
// Swallows the taps that miss the card, and closes. First, so the
|
||||||
|
// card's own controls sit above it.
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.naming = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: min(420px, parent.width - 2 * Theme.gap-lg);
|
||||||
|
height: min(name-sheet.preferred-height, parent.height - 2 * Theme.gap-lg);
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
// A third of the way down, not centred. The field below takes the
|
||||||
|
// keyboard as the sheet appears, and on a tablet the keyboard is
|
||||||
|
// the bottom half of the window — a card centred in the window is a
|
||||||
|
// card centred behind it.
|
||||||
|
y: max(Theme.gap-lg, (parent.height - self.height) / 3);
|
||||||
|
background: Theme.surface;
|
||||||
|
border-radius: Theme.radius;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.rule;
|
||||||
|
|
||||||
|
// Stops a press on the card reaching the scrim behind it.
|
||||||
|
TouchArea { }
|
||||||
|
|
||||||
|
name-sheet := VerticalLayout {
|
||||||
|
padding: Theme.gap-lg;
|
||||||
|
spacing: Theme.gap;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.selected-count == 1
|
||||||
|
? "New collection holding 1 photograph"
|
||||||
|
: "New collection holding " + root.selected-count
|
||||||
|
+ " photographs";
|
||||||
|
color: Theme.ink;
|
||||||
|
font-size: Theme.text-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
name-field := Field {
|
||||||
|
placeholder: "Name this collection";
|
||||||
|
// The card asks one question, so the field answers the
|
||||||
|
// keyboard for it. This is also what raises the on-screen
|
||||||
|
// keyboard on Android — over a field that is on screen,
|
||||||
|
// which is the whole difference from the old path.
|
||||||
|
init => { self.take-focus(); }
|
||||||
|
// Return commits, as it does in every other field here.
|
||||||
|
// Guarded rather than trusting `enabled` on the button
|
||||||
|
// beside it: this is a second way in and it must refuse an
|
||||||
|
// empty name on its own.
|
||||||
|
accepted(text) => {
|
||||||
|
if (text != "") {
|
||||||
|
root.collection-from-selection(text);
|
||||||
|
root.naming = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: Theme.gap-sm;
|
||||||
|
alignment: end;
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Cancel";
|
||||||
|
clicked => { root.naming = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Create";
|
||||||
|
primary: true;
|
||||||
|
// A collection called "New collection" is the state
|
||||||
|
// this sheet exists to prevent, so Create waits for a
|
||||||
|
// name rather than inventing one.
|
||||||
|
enabled: name-field.text != "";
|
||||||
|
clicked => {
|
||||||
|
root.collection-from-selection(name-field.text);
|
||||||
|
root.naming = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -617,6 +617,22 @@ export component Field inherits Rectangle {
|
|||||||
/// because the breakage is in Slint's binding model, not the styling.
|
/// because the breakage is in Slint's binding model, not the styling.
|
||||||
callback edited(string);
|
callback edited(string);
|
||||||
|
|
||||||
|
/// Take the keyboard, and select what is already there.
|
||||||
|
///
|
||||||
|
/// For a sheet whose field is the only thing to do in it: the field arrives
|
||||||
|
/// with the sheet, nothing else on the card can sensibly hold focus, and
|
||||||
|
/// asking the user to tap a box that is the only box is a step with no
|
||||||
|
/// decision in it. On a tablet it is also what raises the on-screen
|
||||||
|
/// keyboard, which is the actual point.
|
||||||
|
///
|
||||||
|
/// A function rather than a property, because focus is an event and not a
|
||||||
|
/// state: bound to a property it would fight anything else that took focus
|
||||||
|
/// afterwards, and re-take it on every unrelated re-evaluation.
|
||||||
|
public function take-focus() {
|
||||||
|
input.focus();
|
||||||
|
input.select-all();
|
||||||
|
}
|
||||||
|
|
||||||
height: Theme.touch-target;
|
height: Theme.touch-target;
|
||||||
border-radius: Theme.radius;
|
border-radius: Theme.radius;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
|
|||||||
Reference in New Issue
Block a user