Let the photographer say which frame the burst stands for
`choose_representative` has been in the catalog since the grouping landed, with tests behind it and nothing calling it. So the frame a folded burst drew was always the earliest one, and the only way to disagree was to open the group and leave it open — which is to say there was no way to disagree at all, because a burst that stays open is a burst that was never collapsed. The earliest frame is the right default and it is deliberately not a judgement: nothing here scores a photograph, and FR-CULL-5 names the failure that rule avoids. But the whole point of a burst is that one of the twelve is better than the other eleven, and the person who knows which is the one looking at them. So a ring on each frame of an open group, ticked on the one the group folds to. It is drawn only while the burst is open, because that is the one moment the alternatives are on screen to be compared — offering the choice on a folded burst would be asking about frames it is hiding. Bottom right, opposite the count in the other corner, clear of the flag and the collection badge and, deliberately, of the trash target: a slip between the ring and the fifth star sets a rating, which is the harmless direction for an ambiguous press. The mark stays live on the frame that already wears it. A disabled TouchArea would let the press fall through to the cell behind it, so tapping the one ring that is ticked would have opened the photograph — and pressing it is a thing the user may mean anyway: it records the choice the default was making silently, which then survives a regroup that finds an earlier frame. Choosing repaints the badges instead of reloading the window, which is what separates it from folding a group up. Folding changes what the grid's query returns; this changes only which cell wears the tick, and the tick has to leave the frame that was carrying it, so the whole window is refilled in the one statement `sync_badges` already runs. The gesture is documented where FR-UI-4 requires it to be documented: in a tagged comment beside the control, which is the only copy. The gesture book, the gesture document and the requirements matrix are regenerated from the tree alongside it.
This commit is contained in:
+22
-13
@@ -5,7 +5,7 @@
|
||||
|
||||
Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it.
|
||||
|
||||
16 gestures, in 2 places.
|
||||
17 gestures, in 2 places.
|
||||
|
||||
## People
|
||||
|
||||
@@ -54,7 +54,7 @@ The right match confidence is a property of your library, not of the model. "Wha
|
||||
|
||||
Touch has no ctrl, so without a mode there is no way to select a second photograph — the first tap would open it. The hold is the fast way in and the button is the one that can be found.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:1292`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:1302`</sub>
|
||||
|
||||
### Add or remove one photograph
|
||||
|
||||
@@ -63,7 +63,7 @@ Touch has no ctrl, so without a mode there is no way to select a second photogra
|
||||
|
||||
While selecting, a tap never opens. That is the whole point of the mode: one meaning per gesture at a time. Press Done to get tap-to-open back.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:1301`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:1311`</sub>
|
||||
|
||||
### Leave selecting
|
||||
|
||||
@@ -71,7 +71,7 @@ While selecting, a tap never opens. That is the whole point of the mode: one mea
|
||||
- **Pointer** — Press Done in the header
|
||||
- **Keyboard** — Escape
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:1309`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:1319`</sub>
|
||||
|
||||
### Pick a photograph up to drag it
|
||||
|
||||
@@ -80,7 +80,7 @@ While selecting, a tap never opens. That is the whole point of the mode: one mea
|
||||
|
||||
A finger on a photograph might be starting a scroll, and for the first half-second the grid assumes it is. Holding says otherwise, and the ring is the grid saying it heard — from there the drag cannot be lost to a scroll. A mouse never waits: the cursor is precise enough that a sideways drag is unambiguous from the first pixel.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:1339`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:1349`</sub>
|
||||
|
||||
### Select a range
|
||||
|
||||
@@ -89,7 +89,7 @@ A finger on a photograph might be starting a scroll, and for the first half-seco
|
||||
|
||||
This replaced a double tap, which had no visible state and could take forty photographs by accident. The run is resolved by the catalog rather than by what is on screen, so the grid can scroll between the two taps — the ranges that hurt on a tablet are longer than a screenful, which is exactly where a finger sweep runs out.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:1404`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:1414`</sub>
|
||||
|
||||
### Find photographs with two people in them
|
||||
|
||||
@@ -98,7 +98,7 @@ This replaced a double tap, which had no visible state and could take forty phot
|
||||
|
||||
"Any of them" is a union and "all of them" is an intersection. The tray is where both terms and the choice between them live, because a filter belongs on the filter bar.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:2086`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:2096`</sub>
|
||||
|
||||
### Resize the thumbnails
|
||||
|
||||
@@ -107,7 +107,7 @@ This replaced a double tap, which had no visible state and could take forty phot
|
||||
|
||||
There is no wheel on a tablet, so without the pinch the cell size could only be changed by a control a finger cannot reach.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:2739`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:2749`</sub>
|
||||
|
||||
### File photographs in a collection
|
||||
|
||||
@@ -116,7 +116,7 @@ There is no wheel on a tablet, so without the pinch the cell size could only be
|
||||
|
||||
The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:2936`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:2946`</sub>
|
||||
|
||||
### Open a photograph
|
||||
|
||||
@@ -125,7 +125,7 @@ The selection is what the drag carries, which is why selecting several is worth
|
||||
|
||||
A tap opens; a tap that *moved* does not. Travel is what separates a deliberate tap from a hand brushing past, and it is the only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:3204`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:3214`</sub>
|
||||
|
||||
### Rate a photograph without opening it
|
||||
|
||||
@@ -135,7 +135,16 @@ A tap opens; a tap that *moved* does not. Travel is what separates a deliberate
|
||||
|
||||
A star has to take the press without it also reaching the cell, or every rating throws the user into develop.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:3324`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:3334`</sub>
|
||||
|
||||
### Choose the frame a folded burst shows
|
||||
|
||||
- **Touch** — Open the burst, then tap the ring on the frame you want
|
||||
- **Pointer** — Open the burst, then click the ring on the frame you want
|
||||
|
||||
A folded burst draws its earliest frame, which is a fact about the clock and not a judgement about the photograph — nothing in this application ranks a frame (FR-CULL-5). But the point of a burst is that one of the twelve is better than the other eleven, and the photographer is the only one who knows which. So the choice is offered on the frames themselves, while they are open and side by side, which is the one moment the alternatives are on screen to be compared.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:3465`</sub>
|
||||
|
||||
### Drop the selection but keep selecting
|
||||
|
||||
@@ -144,7 +153,7 @@ A star has to take the press without it also reaching the cell, or every rating
|
||||
|
||||
Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:4032`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:4126`</sub>
|
||||
|
||||
### Select everything the grid is showing
|
||||
|
||||
@@ -153,4 +162,4 @@ Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the ne
|
||||
|
||||
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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:4049`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:4143`</sub>
|
||||
|
||||
+39
-39
File diff suppressed because one or more lines are too long
+72
-4
@@ -366,15 +366,19 @@ pub fn sync_badges(window: &AppWindow, catalog: &Catalog, ids: &[ImageId]) {
|
||||
|
||||
let model = window.get_library_cells();
|
||||
for (row, id) in ids.iter().enumerate() {
|
||||
let (count, expanded) = match found.get(id) {
|
||||
Some(m) => (m.size as i32, m.expanded),
|
||||
let (count, expanded, representative) = match found.get(id) {
|
||||
Some(m) => (m.size as i32, m.expanded, m.representative),
|
||||
// Not in a burst at all, which is most of a library.
|
||||
None => (0, false),
|
||||
None => (0, false, false),
|
||||
};
|
||||
if let Some(mut cell) = model.row_data(row) {
|
||||
if cell.burst_count != count || cell.burst_expanded != expanded {
|
||||
if cell.burst_count != count
|
||||
|| cell.burst_expanded != expanded
|
||||
|| cell.burst_representative != representative
|
||||
{
|
||||
cell.burst_count = count;
|
||||
cell.burst_expanded = expanded;
|
||||
cell.burst_representative = representative;
|
||||
model.set_row_data(row, cell);
|
||||
}
|
||||
}
|
||||
@@ -411,6 +415,32 @@ pub fn toggle(catalog: &Catalog, image: ImageId) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Name the frame the burst a cell belongs to folds up to.
|
||||
///
|
||||
/// The whole of the arbitration is [`bursts::choose_representative`]'s: a group
|
||||
/// stands for one frame, so the previous pick has to go in the same transaction
|
||||
/// the new one arrives in, and the flag the grouping already carries has to be
|
||||
/// corrected there too or the cell would not change until the next pass.
|
||||
///
|
||||
/// Nothing here decides *which* frame deserves it, and nothing ever will — the
|
||||
/// default is the earliest one, which is a fact about the clock rather than a
|
||||
/// judgement about the photograph (FR-CULL-5). This is the photographer saying
|
||||
/// otherwise about a group they are looking at.
|
||||
///
|
||||
/// Returns whether the catalog took it, so a caller only repaints when there is
|
||||
/// something to repaint. Choosing a frame that is in no burst is not an error
|
||||
/// and writes nothing; it simply cannot arrive from the grid, where the mark is
|
||||
/// drawn on the members of an open group and nowhere else.
|
||||
pub fn choose(catalog: &Catalog, image: ImageId) -> bool {
|
||||
match bursts::choose_representative(catalog.connection(), image) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::warn!("choosing the representative of image {}: {e}", image.0);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -528,4 +558,42 @@ mod tests {
|
||||
let (cat, _dir) = library("no-burst");
|
||||
assert!(!toggle(&cat, ImageId(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_frame_moves_the_mark_the_grid_draws() {
|
||||
// The tick a cell wears is filled in from `memberships`, so that is
|
||||
// where the choice has to appear. A pick recorded only in `burst_pick`
|
||||
// would be honoured at the next pass and invisible until then, which is
|
||||
// the whole reason `choose_representative` corrects the grouping in the
|
||||
// same transaction.
|
||||
let (cat, dir) = library("choose");
|
||||
let conn = cat.connection();
|
||||
let store = ThumbStore::open(&dir).unwrap();
|
||||
let outstanding = bursts::images_without_signature(conn).unwrap();
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
assert_eq!(hash_all(conn, &store, &outstanding, &tx), 2);
|
||||
bursts::regroup(conn, Rules::default()).unwrap();
|
||||
|
||||
let mark = |id: ImageId| bursts::memberships(conn, &[id]).unwrap()[&id].representative;
|
||||
assert!(
|
||||
mark(ImageId(1)),
|
||||
"the earliest frame stands for it by default"
|
||||
);
|
||||
|
||||
assert!(choose(&cat, ImageId(2)));
|
||||
assert!(mark(ImageId(2)), "the chosen frame did not take the mark");
|
||||
assert!(
|
||||
!mark(ImageId(1)),
|
||||
"two frames cannot both stand for one moment"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_frame_that_is_in_no_burst_is_not_an_error() {
|
||||
// Unreachable from the grid, where the mark is drawn on the members of
|
||||
// an open group and nowhere else — but a lone frame arriving here is a
|
||||
// race with a regroup, not a fault.
|
||||
let (cat, _dir) = library("choose-no-burst");
|
||||
assert!(choose(&cat, ImageId(1)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,13 @@ pub const GESTURES: &[Gesture] = &[
|
||||
pointer: "Hover the cell, then click a star",
|
||||
keys: "0 to 5 on the selection",
|
||||
},
|
||||
Gesture {
|
||||
title: "Choose the frame a folded burst shows",
|
||||
section: "Library grid",
|
||||
touch: "Open the burst, then tap the ring on the frame you want",
|
||||
pointer: "Open the burst, then click the ring on the frame you want",
|
||||
keys: "",
|
||||
},
|
||||
Gesture {
|
||||
title: "Drop the selection but keep selecting",
|
||||
section: "Library grid",
|
||||
|
||||
@@ -2818,6 +2818,7 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
// photograph in a library is.
|
||||
burst_count: 0,
|
||||
burst_expanded: false,
|
||||
burst_representative: false,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -6136,6 +6137,41 @@ pub fn wire<F>(
|
||||
});
|
||||
}
|
||||
|
||||
// Name the frame an open burst folds to (FR-CULL-5). The default is the
|
||||
// earliest of them, chosen because it is a fact about the clock and not a
|
||||
// judgement about the photograph; this is the photographer, who is the only
|
||||
// one who knows which of the twelve is the keeper, saying otherwise.
|
||||
//
|
||||
// A repaint of the badges rather than a reload, which is what separates it
|
||||
// from folding a burst up: the grid's query returns the same rows either
|
||||
// way while the group is open, and only the mark on them moves. Refilled
|
||||
// for the whole window in one statement — the tick has to leave the frame
|
||||
// that was carrying it, and that frame is a different cell.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_burst_representative_chosen(move |row| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let ids: Vec<dr_types::ImageId> = ctl
|
||||
.image_ids
|
||||
.borrow()
|
||||
.iter()
|
||||
.map(|id| dr_types::ImageId(*id as u64))
|
||||
.collect();
|
||||
let Some(id) = ids.get(row as usize).copied() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let borrow = ctl.catalog.borrow();
|
||||
let Some(catalog) = borrow.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if crate::bursts::choose(catalog, id) {
|
||||
crate::bursts::sync_badges(&w, catalog, &ids);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// A rating or flag key. Applies to the whole selection, which is what
|
||||
// makes judging a run of frames one keystroke rather than forty.
|
||||
{
|
||||
|
||||
@@ -679,6 +679,9 @@ export component AppWindow inherits Window {
|
||||
callback library-cell-trashed(int);
|
||||
/// The burst mark was clicked on one cell, by row (FR-CULL-5).
|
||||
callback library-burst-toggled(int);
|
||||
/// One frame of an open burst was named as the one the group folds to, by
|
||||
/// row (FR-CULL-5).
|
||||
callback library-burst-representative-chosen(int);
|
||||
/// Move the grid selection to the trash — the `Delete` key.
|
||||
callback library-trash-selection();
|
||||
/// A judgement key was pressed, applying to the whole selection. One of
|
||||
@@ -1720,6 +1723,9 @@ in property <bool> panel-visible: true;
|
||||
cell-rated(i, n) => { root.library-cell-rated(i, n); }
|
||||
cell-trashed(i) => { root.library-cell-trashed(i); }
|
||||
burst-toggled(i) => { root.library-burst-toggled(i); }
|
||||
burst-representative-chosen(i) => {
|
||||
root.library-burst-representative-chosen(i);
|
||||
}
|
||||
trash-selection() => { root.library-trash-selection(); }
|
||||
// Derived from the sidebar's own selection rather than
|
||||
// mirrored in a second property: `-1` is already the sentinel
|
||||
|
||||
@@ -472,6 +472,11 @@ export struct LibraryCell {
|
||||
// needs a way back, and a control that disappears once used is a control
|
||||
// nobody finds twice.
|
||||
burst-expanded: bool,
|
||||
// Whether this is the frame the group folds up to — the earliest of them
|
||||
// until the user says otherwise. Read only while the group is open: folded,
|
||||
// the one cell on screen is the representative by construction, and a mark
|
||||
// saying so would be telling the user what they can already see.
|
||||
burst-representative: bool,
|
||||
}
|
||||
|
||||
// The photo roll: the grid's loaded window along the foot of the develop view.
|
||||
@@ -1208,6 +1213,11 @@ export component LibraryGrid inherits Rectangle {
|
||||
/// group is currently doing, so the mark cannot get out of step with the
|
||||
/// query that actually hides the frames.
|
||||
callback burst-toggled(int);
|
||||
/// A frame of an open burst was named as the one it folds to (FR-CULL-5),
|
||||
/// by row. Nothing here decides *which* frame deserves it — the catalog's
|
||||
/// default is the earliest, a fact about the clock rather than a judgement
|
||||
/// about the photograph, and this is the photographer overruling it.
|
||||
callback burst-representative-chosen(int);
|
||||
/// Whether the grid is currently listing the trash rather than the
|
||||
/// library. Suppresses the per-cell trash target, which would be inert
|
||||
/// there — `plan_trash` skips an already-trashed image — and offering a
|
||||
@@ -3451,6 +3461,90 @@ export component LibraryGrid inherits Rectangle {
|
||||
clicked => { root.burst-toggled(i); }
|
||||
}
|
||||
}
|
||||
|
||||
// GESTURE: Choose the frame a folded burst shows
|
||||
// where: Library grid
|
||||
// touch: Open the burst, then tap the ring on the
|
||||
// frame you want
|
||||
// pointer: Open the burst, then click the ring on the
|
||||
// frame you want
|
||||
// why: A folded burst draws its earliest frame,
|
||||
// which is a fact about the clock and not a
|
||||
// judgement about the photograph — nothing
|
||||
// in this application ranks a frame
|
||||
// (FR-CULL-5). But the point of a burst is
|
||||
// that one of the twelve is better than the
|
||||
// other eleven, and the photographer is the
|
||||
// only one who knows which. So the choice is
|
||||
// offered on the frames themselves, while
|
||||
// they are open and side by side, which is
|
||||
// the one moment the alternatives are on
|
||||
// screen to be compared.
|
||||
//
|
||||
// Drawn on every frame of an open group and on none of
|
||||
// a folded one. Folded, the single cell showing *is*
|
||||
// the representative, so the mark would state the
|
||||
// obvious and the frames it offers to choose between
|
||||
// would not be on screen to choose from.
|
||||
//
|
||||
// Bottom right, opposite the count in the other corner:
|
||||
// "this many frames" at one end and "this is the one"
|
||||
// at the other, clear of the flag and the collection
|
||||
// badge in the top two. A slip between it and ★5 sets a
|
||||
// rating — the harmless direction for an ambiguous
|
||||
// press, which is why it is on this side of the cell
|
||||
// and not beside the trash target.
|
||||
//
|
||||
// A child of `cell-touch` for the reason the stars are:
|
||||
// a press here must be spent on the choice and must not
|
||||
// also reach `cell-clicked` and open the photograph.
|
||||
if cell.burst-expanded: Rectangle {
|
||||
x: parent.width - self.width - 6px;
|
||||
y: parent.height - self.height - 26px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: self.height / 2;
|
||||
background: Theme.surface;
|
||||
border-width: 1px;
|
||||
border-color: cell.burst-representative
|
||||
? Theme.selected-ring : Theme.rule;
|
||||
|
||||
// Ticked or empty, and that is the whole of the
|
||||
// state: which frame stands for the moment is said
|
||||
// by the presence of a mark rather than by a colour
|
||||
// (NFR-A11Y-3), so it survives being read at grid
|
||||
// scale and by an eye that does not separate the
|
||||
// two hues.
|
||||
if cell.burst-representative: Icon {
|
||||
name: "check";
|
||||
ink: Theme.active;
|
||||
size: 11px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
}
|
||||
|
||||
// Live on the chosen frame too, rather than inert
|
||||
// there. A disabled TouchArea lets the press fall
|
||||
// through to the cell behind it, so tapping the one
|
||||
// mark that is already ticked would open the
|
||||
// photograph — a control that does something
|
||||
// unrelated when pressed twice. Pressing it instead
|
||||
// says "yes, this one", which is a thing the user
|
||||
// may mean: the default is the earliest frame, and
|
||||
// recording the choice keeps it through a regroup
|
||||
// that finds an earlier one.
|
||||
TouchArea {
|
||||
// Grown past the drawn ring to FR-UI-3's
|
||||
// minimum and centred on it, the way the stars
|
||||
// beside it are.
|
||||
width: max(parent.width, Theme.touch-target);
|
||||
height: max(parent.height, Theme.touch-target);
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.burst-representative-chosen(i); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Where the run would land. A bar in the gutter beside the
|
||||
|
||||
Reference in New Issue
Block a user