Float the selection bar over the grid instead of above it

Selecting the first photograph inserted a 40px row into the view's
vertical flow, so every cell in the grid moved down by it. The act of
selecting shifted the thing being selected out from under the finger —
and a second tap aimed at the neighbour landed on the row below it,
which is the worst possible response to a gesture whose whole job is to
say "this one".

The bar is a floating one now, at the foot of the view. Nothing above it
is re-laid out, so selecting changes what is drawn and never where.

The grid's viewport grows by the same 40px while the bar is there rather
than the Flickable shrinking, which is what keeps that change invisible
too: every cell stays exactly where it was and there is simply further to
scroll, so the last row can be brought clear of the bar instead of being
trapped under it.

It also swallows presses that land on it. A bar floating over the grid is
a bar a thumb can reach for and miss into.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 09:47:20 +02:00
co-authored by Claude Opus 5
parent ea88216008
commit b120ce48ec
4 changed files with 207 additions and 186 deletions
+14 -14
View File
@@ -85,20 +85,6 @@ pub const GESTURES: &[Gesture] = &[
pointer: "Open the People chip on the filter bar, click each name, then switch the chip beside them to \"all of them\"",
keys: "",
},
Gesture {
title: "Drop the selection but keep selecting",
section: "Library grid",
touch: "Press Clear in the selection strip",
pointer: "Press Clear in the selection strip",
keys: "",
},
Gesture {
title: "Select everything the grid is showing",
section: "Library grid",
touch: "While selecting, press \"Select all\"",
pointer: "While selecting, press \"Select all\"",
keys: "",
},
Gesture {
title: "Resize the thumbnails",
section: "Library grid",
@@ -127,4 +113,18 @@ pub const GESTURES: &[Gesture] = &[
pointer: "Hover the cell, then click a star",
keys: "0 to 5 on the selection",
},
Gesture {
title: "Drop the selection but keep selecting",
section: "Library grid",
touch: "Press Clear in the selection strip",
pointer: "Press Clear in the selection strip",
keys: "",
},
Gesture {
title: "Select everything the grid is showing",
section: "Library grid",
touch: "While selecting, press \"Select all\"",
pointer: "While selecting, press \"Select all\"",
keys: "",
},
];
+145 -124
View File
@@ -2325,129 +2325,6 @@ export component LibraryGrid inherits Rectangle {
}
}
// --- what is selected, and what to do with it --------------------
//
// The count and these actions used to live only in the header row,
// which scrolls sideways: on a tablet "12 selected" and everything
// beside it sat past the right-hand edge, so a selection was something
// you could make and then not see. A selection you cannot see is one
// you act on by accident.
//
// Its own strip, like the date range's, appearing only while there is
// a selection — so it costs nothing the rest of the time, and when it
// is there it has the width of the window rather than the tail of a
// row that did not fit.
if root.selected-count > 0: Rectangle {
height: 40px;
background: Theme.surface;
HorizontalLayout {
width: 100%;
height: 100%;
padding-left: Theme.gap;
padding-right: Theme.gap;
spacing: Theme.gap-sm;
alignment: start;
// While a range is armed the strip stops reporting and starts
// instructing. The count is still true, but it is not what the
// user needs to read: they have pressed something that changes
// what the *next* tap means, and a mode with no visible state
// is the double-tap gesture this replaces.
if root.ranging: Value {
text: "Tap the last photograph";
modified: true;
font-weight: 600;
vertical-alignment: center;
overflow: elide;
}
if root.ranging: Rectangle { width: Theme.gap; }
// Armed by mistake, or armed and thought better of. Without
// this the only way out is to tap a cell, which takes a run the
// user did not want and then has to be undone by hand.
if root.ranging: Button {
text: "Cancel";
y: (parent.height - self.height) / 2;
clicked => { root.ranging = false; }
}
// State rather than a label: it is what the buttons beside it
// act on.
if !root.ranging: Value {
text: root.selected-count + (root.selected-count == 1
? " photograph selected" : " photographs selected");
modified: true;
font-weight: 600;
vertical-alignment: center;
overflow: elide;
}
if !root.ranging: Rectangle { width: Theme.gap; }
// The way out that is not "undo every tap". Distinct from
// "Done", which leaves select mode entirely: clearing keeps the
// mode, so the next selection can start straight away.
// GESTURE: Drop the selection but keep selecting
// where: Library grid
// touch: Press Clear in the selection strip
// pointer: Press Clear in the selection strip
// why: Distinct from Done, which leaves the mode
// entirely. Clearing keeps it, so the next
// selection can start straight away.
if !root.ranging: Button {
text: "Clear";
y: (parent.height - self.height) / 2;
clicked => { root.clear-selection(); }
}
// Everything the grid is showing. Cheap to offer and tedious to
// do by hand: a scoped grid of two hundred frames is two hundred
// taps otherwise, and "all of them, except those three" is a far
// more common shape than the taps it took to say it.
// GESTURE: Select everything the grid is showing
// where: Library grid
// touch: While selecting, press "Select all"
// pointer: While selecting, press "Select all"
// why: A scoped grid of two hundred frames is two hundred
// taps otherwise, and "all of them, except those
// three" is a far more common shape than the taps it
// took to say it.
if !root.ranging: Button {
text: "Select all";
y: (parent.height - self.height) / 2;
clicked => { root.select-all(); }
}
// The visible half of `ranging` — see its declaration for why
// the double-tap it replaces was not good enough. Deliberately
// an unfinished sentence: the ellipsis is the promise that a
// second tap is coming.
if !root.ranging: Button {
text: "Select to…";
y: (parent.height - self.height) / 2;
clicked => { root.ranging = true; }
}
// Filing a selection into a collection that does not exist yet
// took four steps: make a collection, find it, select the
// photographs again, add them. One press instead, which is how
// 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.
if !root.ranging: Button {
text: "New collection";
primary: true;
y: (parent.height - self.height) / 2;
clicked => { root.naming = true; }
}
}
}
// --- the date range's ends ---------------------------------------
//
// Its own strip, a sibling of the chips rather than a child of them.
@@ -2944,7 +2821,17 @@ export component LibraryGrid inherits Rectangle {
// scrollbar has to represent 23,971 images or there is no way to
// reach image 20,000 — dragging it must be a real address, and the
// window is swapped underneath to match.
viewport-height: root.total-rows * (root.cell-size + Theme.gap) + Theme.gap;
// Plus room for the selection bar, which floats over the
// foot of the grid rather than sitting above it. Extending the
// viewport rather than shrinking the Flickable is what keeps
// the change invisible: every cell stays exactly where it was
// and there is simply further to scroll, so the last row can be
// brought clear of the bar instead of being trapped under it.
property <length> selection-inset:
root.selected-count > 0 ? 40px : 0px;
viewport-height: root.total-rows * (root.cell-size + Theme.gap)
+ Theme.gap + self.selection-inset;
// TRACES: FR-CAT-7
// Stay inside the content when the content shrinks.
@@ -3825,6 +3712,140 @@ export component LibraryGrid inherits Rectangle {
}
}
// --- what is selected, and what to do with it ------------------------
//
// The count and these actions used to live only in the header row,
// which scrolls sideways: on a tablet "12 selected" and everything
// beside it sat past the right-hand edge, so a selection was something
// you could make and then not see. A selection you cannot see is one
// you act on by accident.
//
// **Over the grid, not above it.** As a row in the vertical flow it
// appeared the instant the first photograph was selected, and every
// cell in the grid moved 40px — so the act of selecting shifted the
// thing being selected out from under the finger, and a second tap
// aimed at the neighbouring photograph landed on the row below it.
//
// A floating bar cannot do that: nothing above it is re-laid out, so
// selecting changes what is drawn and never where. The grid's viewport
// grows by the same 40px while it is there (see `selection-inset`), so
// the last row can still be scrolled clear of it.
if root.selected-count > 0: Rectangle {
y: parent.height - self.height;
height: 40px;
background: Theme.surface;
// Swallows presses that land on the bar rather than letting them
// through to the cell beneath. A bar floating over the grid is a bar a
// thumb can reach for and miss into.
TouchArea { }
HorizontalLayout {
width: 100%;
height: 100%;
padding-left: Theme.gap;
padding-right: Theme.gap;
spacing: Theme.gap-sm;
alignment: start;
// While a range is armed the strip stops reporting and starts
// instructing. The count is still true, but it is not what the
// user needs to read: they have pressed something that changes
// what the *next* tap means, and a mode with no visible state
// is the double-tap gesture this replaces.
if root.ranging: Value {
text: "Tap the last photograph";
modified: true;
font-weight: 600;
vertical-alignment: center;
overflow: elide;
}
if root.ranging: Rectangle { width: Theme.gap; }
// Armed by mistake, or armed and thought better of. Without
// this the only way out is to tap a cell, which takes a run the
// user did not want and then has to be undone by hand.
if root.ranging: Button {
text: "Cancel";
y: (parent.height - self.height) / 2;
clicked => { root.ranging = false; }
}
// State rather than a label: it is what the buttons beside it
// act on.
if !root.ranging: Value {
text: root.selected-count + (root.selected-count == 1
? " photograph selected" : " photographs selected");
modified: true;
font-weight: 600;
vertical-alignment: center;
overflow: elide;
}
if !root.ranging: Rectangle { width: Theme.gap; }
// The way out that is not "undo every tap". Distinct from
// "Done", which leaves select mode entirely: clearing keeps the
// mode, so the next selection can start straight away.
// GESTURE: Drop the selection but keep selecting
// where: Library grid
// touch: Press Clear in the selection strip
// pointer: Press Clear in the selection strip
// why: Distinct from Done, which leaves the mode
// entirely. Clearing keeps it, so the next
// selection can start straight away.
if !root.ranging: Button {
text: "Clear";
y: (parent.height - self.height) / 2;
clicked => { root.clear-selection(); }
}
// Everything the grid is showing. Cheap to offer and tedious to
// do by hand: a scoped grid of two hundred frames is two hundred
// taps otherwise, and "all of them, except those three" is a far
// more common shape than the taps it took to say it.
// GESTURE: Select everything the grid is showing
// where: Library grid
// touch: While selecting, press "Select all"
// pointer: While selecting, press "Select all"
// why: A scoped grid of two hundred frames is two hundred
// taps otherwise, and "all of them, except those
// three" is a far more common shape than the taps it
// took to say it.
if !root.ranging: Button {
text: "Select all";
y: (parent.height - self.height) / 2;
clicked => { root.select-all(); }
}
// The visible half of `ranging` — see its declaration for why
// the double-tap it replaces was not good enough. Deliberately
// an unfinished sentence: the ellipsis is the promise that a
// second tap is coming.
if !root.ranging: Button {
text: "Select to…";
y: (parent.height - self.height) / 2;
clicked => { root.ranging = true; }
}
// Filing a selection into a collection that does not exist yet
// took four steps: make a collection, find it, select the
// photographs again, add them. One press instead, which is how
// 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.
if !root.ranging: Button {
text: "New collection";
primary: true;
y: (parent.height - self.height) / 2;
clicked => { root.naming = true; }
}
}
}
// TRACES: FR-UI-2 | FR-UI-4
// The gesture reference. Last of the sheets, and above them all, because it
// is the one a user opens *because* another one confused them.