Let a collection be picked up, rearranged, and emptied after the fact
Collections could be made and filled and never reorganised. Nesting had a drag; un-nesting had nothing, in either direction — "All photographs" refused every drop, which is right for a photograph and wrong for a collection, which has a top level to be returned to. So a collection put inside another was in there permanently. Right-click deleted an *empty* collection outright and refused otherwise, which is wrong in both directions at once: destructive with no confirmation, and no way at all to delete a collection that held anything without emptying it by hand, child by child. And a photograph could only leave the collection the grid was scoped to, since that is the only one a button in the header can name — the cell's badge says a photograph is in three collections and never which three. Three ways in, one vocabulary: **Hold a row.** The tree is inside a Flickable, which claims any drag beginning inside it, so with a finger a drag on a row is a scroll until something says otherwise. The hold is that something. It lifts the row — drawn before anything moves, so the gesture says it has been understood — and then what the user does decides which of two things they meant: move, and it is a rearrangement; let go, and it is the row menu. The same fork the grid already uses to tell hold-to-select from drag-to-file. `decide_release` is that fork, and it is tested, because getting it wrong one way puts a sheet over every tidied tree and the other way makes the menu unreachable by touch. **The row menu.** Rename, new collection inside, move to top level, keep offline, delete. Deleting asks once when there is anything to lose and says what survives: the photographs stay in the library, and nested collections move up rather than going with it — which is what the catalog does, and what a user would never assume. An empty collection goes on the first press, because a dialogue about losing nothing is how people learn to dismiss dialogues. **"Collections…" on a selection.** Every collection the selection is filed in, each with a count — "3 of 40", so nobody takes forty photographs out of a collection thirty-seven were never in — and a way out of any of them without navigating there first. The long press used to open the offline question by itself. That question is one item in this menu now: there is one hold per row, and while it was spent on a single action nothing else the tree can do had a touch route at all. Nothing is lost — the tray on the row keeps its tap, and the question gains a full-width control in place of a 30px icon in a row shorter than the touch minimum. The row-press handler moves to `collections_ui` with the rest of what a collection row does; it lived in `library_ui` only because it opened that prompt.
This commit is contained in:
+66
-19
@@ -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.
|
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.
|
||||||
|
|
||||||
30 gestures, in 3 places.
|
35 gestures, in 4 places.
|
||||||
|
|
||||||
## Develop
|
## Develop
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ Sampling a neutral is the first move of the tonal pass — every colour judgemen
|
|||||||
|
|
||||||
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between.
|
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:1990`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2064`</sub>
|
||||||
|
|
||||||
### Move a magnified photograph about
|
### Move a magnified photograph about
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as m
|
|||||||
|
|
||||||
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
|
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2143`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2217`</sub>
|
||||||
|
|
||||||
### Take back the last change
|
### Take back the last change
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ Only once there is something outside the viewport to reach, which is why the cur
|
|||||||
|
|
||||||
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
|
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2310`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2384`</sub>
|
||||||
|
|
||||||
### Do it again after taking it back
|
### Do it again after taking it back
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
|||||||
- **Pointer** — Click it, or press Redo in the History header
|
- **Pointer** — Click it, or press Redo in the History header
|
||||||
- **Keyboard** — Ctrl+Shift+Z
|
- **Keyboard** — Ctrl+Shift+Z
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2323`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2397`</sub>
|
||||||
|
|
||||||
### Copy the settings from this photograph
|
### Copy the settings from this photograph
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
|||||||
|
|
||||||
The panel is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
|
The panel is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2356`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2430`</sub>
|
||||||
|
|
||||||
### Paste the settings onto this photograph
|
### Paste the settings onto this photograph
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ The panel is the copy that has to work: a tablet has no modifier key to hold and
|
|||||||
|
|
||||||
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
|
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2368`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2442`</sub>
|
||||||
|
|
||||||
### Change which group of adjustments is on screen
|
### Change which group of adjustments is on screen
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ The button names what would be pasted — "3 adjustments", and whether the crop
|
|||||||
|
|
||||||
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
|
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2396`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2470`</sub>
|
||||||
|
|
||||||
### Look at the photograph at 1:1
|
### Look at the photograph at 1:1
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ The groups are whatever the operation set declares itself to be about, so there
|
|||||||
|
|
||||||
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans.
|
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2431`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2505`</sub>
|
||||||
|
|
||||||
### Move to the next or previous photograph
|
### Move to the next or previous photograph
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
|
|||||||
|
|
||||||
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing.
|
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2461`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2535`</sub>
|
||||||
|
|
||||||
### See the photograph before you edited it
|
### See the photograph before you edited it
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ The edit on screen is saved on the way out, so stepping through a folder is as m
|
|||||||
|
|
||||||
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
|
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2585`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2659`</sub>
|
||||||
|
|
||||||
### Put one control back to its default
|
### Put one control back to its default
|
||||||
|
|
||||||
@@ -132,6 +132,44 @@ Disabling a layer is the before-and-after a local edit constantly wants, so it i
|
|||||||
|
|
||||||
<sub>`ui/dr-ui/ui/masks.slint:163`</sub>
|
<sub>`ui/dr-ui/ui/masks.slint:163`</sub>
|
||||||
|
|
||||||
|
## Collections sidebar
|
||||||
|
|
||||||
|
### Pick a collection up to rearrange the tree
|
||||||
|
|
||||||
|
- **Touch** — Press and hold it until it lifts, then drag it
|
||||||
|
- **Pointer** — Drag it, or hold it until it lifts and then drag
|
||||||
|
|
||||||
|
The tree is inside a Flickable, which claims any drag beginning inside it — so with a finger a drag on a row is a scroll until something says otherwise. The hold is that something, and it is what every mobile list already uses to pick a row up. The row lifts the moment it fires, so the gesture says it has been understood before anything moves.
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/collections.slint:335`</sub>
|
||||||
|
|
||||||
|
### Act on a collection — rename, nest, un-nest, delete
|
||||||
|
|
||||||
|
- **Touch** — Press and hold the collection, then let go without moving
|
||||||
|
- **Pointer** — Right-click it
|
||||||
|
|
||||||
|
The hold arms a drag and opens this menu, and which one you get is decided by whether you moved — the same fork the grid uses. One menu for everything done to a row, because there is one hold per row: while the hold opened the offline question by itself, nothing else the tree can do had a touch route.
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/collections.slint:346`</sub>
|
||||||
|
|
||||||
|
### Take a collection back out of the one it is nested in
|
||||||
|
|
||||||
|
- **Touch** — Hold it, then drag it onto "All photographs" — or let go and choose "Move to top level"
|
||||||
|
- **Pointer** — Drag it onto "All photographs", or right-click it and choose "Move to top level"
|
||||||
|
|
||||||
|
Nesting is a drag of one row onto another, and its inverse had no gesture at all: "All photographs" refused every drop, which is right for a photograph — it is already in the library — and wrong for a collection, which has a top level to be returned to. Without it a collection dragged into another was in there permanently.
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/collections.slint:356`</sub>
|
||||||
|
|
||||||
|
### Rename a collection
|
||||||
|
|
||||||
|
- **Touch** — Hold the collection, then "Rename"
|
||||||
|
- **Pointer** — Double-click its name, or right-click it and choose "Rename"
|
||||||
|
|
||||||
|
Double-click is what a file manager and a Lightroom panel use for the same thing, so it needs no discovering — but nothing on screen says so, which is what the menu item is for.
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/collections.slint:369`</sub>
|
||||||
|
|
||||||
## People
|
## People
|
||||||
|
|
||||||
### Pull a face out of the wrong person
|
### Pull a face out of the wrong person
|
||||||
@@ -223,7 +261,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.
|
"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:2096`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:2102`</sub>
|
||||||
|
|
||||||
### Resize the thumbnails
|
### Resize the thumbnails
|
||||||
|
|
||||||
@@ -232,7 +270,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.
|
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:2749`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:2755`</sub>
|
||||||
|
|
||||||
### File photographs in a collection
|
### File photographs in a collection
|
||||||
|
|
||||||
@@ -241,7 +279,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.
|
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:2946`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:2952`</sub>
|
||||||
|
|
||||||
### Open a photograph
|
### Open a photograph
|
||||||
|
|
||||||
@@ -250,7 +288,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.
|
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:3214`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:3220`</sub>
|
||||||
|
|
||||||
### Rate a photograph without opening it
|
### Rate a photograph without opening it
|
||||||
|
|
||||||
@@ -260,7 +298,7 @@ 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.
|
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:3334`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:3340`</sub>
|
||||||
|
|
||||||
### Choose the frame a folded burst shows
|
### Choose the frame a folded burst shows
|
||||||
|
|
||||||
@@ -269,7 +307,7 @@ A star has to take the press without it also reaching the cell, or every rating
|
|||||||
|
|
||||||
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.
|
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>
|
<sub>`ui/dr-ui/ui/library.slint:3471`</sub>
|
||||||
|
|
||||||
### Drop the selection but keep selecting
|
### Drop the selection but keep selecting
|
||||||
|
|
||||||
@@ -278,7 +316,7 @@ A folded burst draws its earliest frame, which is a fact about the clock and not
|
|||||||
|
|
||||||
Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away.
|
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:4126`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:4132`</sub>
|
||||||
|
|
||||||
### Select everything the grid is showing
|
### Select everything the grid is showing
|
||||||
|
|
||||||
@@ -287,4 +325,13 @@ 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.
|
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:4143`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:4149`</sub>
|
||||||
|
|
||||||
|
### Take photographs out of a collection
|
||||||
|
|
||||||
|
- **Touch** — Select them, then "Collections…" in the selection bar
|
||||||
|
- **Pointer** — Select them, then "Collections…" in the selection bar
|
||||||
|
|
||||||
|
The badge on a cell says a photograph is filed in three collections and never which. This is the sheet that names them, and the only way out of one the grid is not currently scoped to.
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/library.slint:4264`</sub>
|
||||||
|
|||||||
+38
-38
File diff suppressed because one or more lines are too long
+794
-44
@@ -206,6 +206,43 @@ pub struct CollectionsController {
|
|||||||
/// works — the drop callback carries only the target's id, so what is being
|
/// works — the drop callback carries only the target's id, so what is being
|
||||||
/// dropped has to be remembered rather than inspected.
|
/// dropped has to be remembered rather than inspected.
|
||||||
dragging_collection: RefCell<Option<CollectionId>>,
|
dragging_collection: RefCell<Option<CollectionId>>,
|
||||||
|
/// TRACES: FR-UI-3 | FR-UI-4
|
||||||
|
/// The collection a hold has picked up, and the timer that picks it up.
|
||||||
|
///
|
||||||
|
/// The tree is inside a Flickable, which claims any drag beginning inside
|
||||||
|
/// it — so with a finger, a drag on a row is a scroll unless something says
|
||||||
|
/// otherwise first. The hold is that something. It arms the drag *and*
|
||||||
|
/// opens the row menu, and which of the two the user gets is decided by
|
||||||
|
/// whether they then moved: the same fork the grid already uses to tell a
|
||||||
|
/// hold-to-select from a drag-to-file.
|
||||||
|
lifted: RefCell<Option<CollectionId>>,
|
||||||
|
/// Whether the lifted row's drag actually began. Reset by the press that
|
||||||
|
/// arms the next one, so a release can tell a rearrangement from a menu.
|
||||||
|
drag_began: std::cell::Cell<bool>,
|
||||||
|
/// The hold timer for a sidebar row. One, replaced per press, so a press
|
||||||
|
/// that became a scroll leaves nothing queued to fire over the list the
|
||||||
|
/// user is now scrolling.
|
||||||
|
row_hold_timer: RefCell<Option<slint::Timer>>,
|
||||||
|
/// Whether each visible row has a parent, in `row_ids` order — what decides
|
||||||
|
/// whether "All photographs" lights up as a drop target. Kept beside the
|
||||||
|
/// rows it indexes rather than queried per drag: `refresh_tree` already has
|
||||||
|
/// the parent in hand, and a query would answer for a tree that may have
|
||||||
|
/// been rebuilt since.
|
||||||
|
row_nested: RefCell<Vec<bool>>,
|
||||||
|
/// The collection the row menu is open on.
|
||||||
|
///
|
||||||
|
/// Held here as well as in the window's title property because the two say
|
||||||
|
/// different things: the property is what the sheet *draws*, and this is
|
||||||
|
/// what every action *acts on*. A rename committed from the menu changes
|
||||||
|
/// the first and must not change the second.
|
||||||
|
menu_for: RefCell<Option<CollectionId>>,
|
||||||
|
/// Whether the menu's delete has been asked once and is waiting to be
|
||||||
|
/// confirmed.
|
||||||
|
///
|
||||||
|
/// A `Cell` rather than a window property alone so the decision is made in
|
||||||
|
/// Rust: the sheet must not be able to reach the destructive branch by
|
||||||
|
/// flipping a bit of its own.
|
||||||
|
menu_confirming: std::cell::Cell<bool>,
|
||||||
/// The collection a drag is currently over, by id.
|
/// The collection a drag is currently over, by id.
|
||||||
///
|
///
|
||||||
/// Only the spring needs this — the *drop* is hit-tested by Slint and
|
/// Only the spring needs this — the *drop* is hit-tested by Slint and
|
||||||
@@ -289,6 +326,118 @@ pub fn decide_drop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-7
|
||||||
|
/// What the row menu's Delete should do on this press.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DeleteStep {
|
||||||
|
/// Delete it now. Nothing is lost that the user cannot see is nothing.
|
||||||
|
Now,
|
||||||
|
/// Say what goes, and wait to be asked again.
|
||||||
|
Confirm,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-7
|
||||||
|
/// Whether deleting this collection needs to be confirmed first.
|
||||||
|
///
|
||||||
|
/// The gesture this replaced refused outright whenever the collection held
|
||||||
|
/// anything, which made a full collection undeletable without emptying it by
|
||||||
|
/// hand — child by child, since a parent counts its descendants. Refusing is
|
||||||
|
/// not a safety property; it is the absence of one, because the user goes and
|
||||||
|
/// does the same thing the long way round.
|
||||||
|
///
|
||||||
|
/// So: an empty collection goes on the first press, because there is nothing
|
||||||
|
/// to warn about and a dialogue asking "delete this empty thing?" is the kind
|
||||||
|
/// that teaches people to dismiss dialogues. Anything else is asked once.
|
||||||
|
///
|
||||||
|
/// Split from the handler because this is the whole of the safety rule, and a
|
||||||
|
/// handler needs a window to run.
|
||||||
|
pub fn decide_delete(holds: usize, children: usize, confirmed: bool) -> DeleteStep {
|
||||||
|
if confirmed || (holds == 0 && children == 0) {
|
||||||
|
DeleteStep::Now
|
||||||
|
} else {
|
||||||
|
DeleteStep::Confirm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-7
|
||||||
|
/// The line under the menu's title: what this collection directly holds.
|
||||||
|
///
|
||||||
|
/// Direct members and direct children, not the sidebar's deep count, because
|
||||||
|
/// this line sits above a Delete — and delete drops *this* collection's member
|
||||||
|
/// rows while promoting its children rather than taking them. A number here
|
||||||
|
/// that counted descendants' photographs would be describing something the
|
||||||
|
/// button below it does not do.
|
||||||
|
pub fn menu_detail(holds: usize, children: usize) -> String {
|
||||||
|
let photos = match holds {
|
||||||
|
0 => "no photographs".to_string(),
|
||||||
|
1 => "1 photograph".to_string(),
|
||||||
|
n => format!("{n} photographs"),
|
||||||
|
};
|
||||||
|
match children {
|
||||||
|
0 => photos,
|
||||||
|
1 => format!("{photos} · 1 collection inside"),
|
||||||
|
n => format!("{photos} · {n} collections inside"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-7
|
||||||
|
/// What deleting this collection actually does, in full.
|
||||||
|
///
|
||||||
|
/// Every clause here is one a user has got wrong about a collections feature
|
||||||
|
/// before: that deleting a collection deletes the photographs (it does not —
|
||||||
|
/// membership is a join table), and that deleting a parent takes the
|
||||||
|
/// collections nested in it (it does not — [`dr_catalog::collections::delete`]
|
||||||
|
/// promotes them, because losing a subtree because its container was tidied
|
||||||
|
/// away is not recoverable).
|
||||||
|
pub fn delete_warning(name: &str, holds: usize, children: usize) -> String {
|
||||||
|
let mut out = format!("Deleting “{name}” ");
|
||||||
|
match (holds, children) {
|
||||||
|
(0, _) => out.push_str("removes it from the sidebar."),
|
||||||
|
(1, _) => out.push_str("takes 1 photograph out of it."),
|
||||||
|
(n, _) => out.push_str(&format!("takes {n} photographs out of it.")),
|
||||||
|
}
|
||||||
|
if holds > 0 {
|
||||||
|
out.push_str(" They stay in your library and in every other collection they are in.");
|
||||||
|
}
|
||||||
|
match children {
|
||||||
|
0 => {}
|
||||||
|
1 => {
|
||||||
|
out.push_str(" The collection inside it moves up one level rather than going with it.")
|
||||||
|
}
|
||||||
|
n => out.push_str(&format!(
|
||||||
|
" The {n} collections inside it move up one level rather than going with it."
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-UI-3 | FR-UI-4
|
||||||
|
/// What letting go of a held collection row means.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Release {
|
||||||
|
/// The hold fired and nothing moved: the user is asking what can be done
|
||||||
|
/// to this collection.
|
||||||
|
OpenMenu(CollectionId),
|
||||||
|
/// Either the hold never fired — an ordinary tap, which selects — or it
|
||||||
|
/// did and the row was then dragged, in which case the drop has already
|
||||||
|
/// done the work.
|
||||||
|
Nothing,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-UI-3 | FR-UI-4
|
||||||
|
/// Decide what a release does, from what the press turned into.
|
||||||
|
///
|
||||||
|
/// The whole of the fork that lets one gesture mean two things, and the reason
|
||||||
|
/// it is worth a test: get it wrong in the direction of `OpenMenu` and every
|
||||||
|
/// rearrangement ends with a sheet over the tree the user just tidied; get it
|
||||||
|
/// wrong the other way and the menu is unreachable with a finger.
|
||||||
|
pub fn decide_release(held: Option<CollectionId>, dragged: bool) -> Release {
|
||||||
|
match held {
|
||||||
|
Some(id) if !dragged => Release::OpenMenu(id),
|
||||||
|
_ => Release::Nothing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl CollectionsController {
|
impl CollectionsController {
|
||||||
pub fn new(activity: Rc<crate::activity::ActivityLog>) -> Rc<Self> {
|
pub fn new(activity: Rc<crate::activity::ActivityLog>) -> Rc<Self> {
|
||||||
Rc::new(Self {
|
Rc::new(Self {
|
||||||
@@ -645,6 +794,7 @@ pub fn refresh_tree(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog
|
|||||||
let mut ids = Vec::new();
|
let mut ids = Vec::new();
|
||||||
let mut smart = Vec::new();
|
let mut smart = Vec::new();
|
||||||
let mut has_kids = Vec::new();
|
let mut has_kids = Vec::new();
|
||||||
|
let mut nested = Vec::new();
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
|
|
||||||
for row in &rows {
|
for row in &rows {
|
||||||
@@ -671,6 +821,7 @@ pub fn refresh_tree(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog
|
|||||||
ids.push(id);
|
ids.push(id);
|
||||||
smart.push(row.collection.kind == CollectionKind::Smart);
|
smart.push(row.collection.kind == CollectionKind::Smart);
|
||||||
has_kids.push(row.has_children);
|
has_kids.push(row.has_children);
|
||||||
|
nested.push(row.collection.parent.is_some());
|
||||||
out.push(CollectionRow {
|
out.push(CollectionRow {
|
||||||
id: id.0 as i32,
|
id: id.0 as i32,
|
||||||
name: row.collection.name.as_str().into(),
|
name: row.collection.name.as_str().into(),
|
||||||
@@ -692,6 +843,7 @@ pub fn refresh_tree(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog
|
|||||||
*ctl.row_ids.borrow_mut() = ids;
|
*ctl.row_ids.borrow_mut() = ids;
|
||||||
*ctl.row_smart.borrow_mut() = smart;
|
*ctl.row_smart.borrow_mut() = smart;
|
||||||
*ctl.row_has_children.borrow_mut() = has_kids;
|
*ctl.row_has_children.borrow_mut() = has_kids;
|
||||||
|
*ctl.row_nested.borrow_mut() = nested;
|
||||||
window.set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out)));
|
window.set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out)));
|
||||||
|
|
||||||
// The rows are what `sync_reorderable` reads, so it has to be told they
|
// The rows are what `sync_reorderable` reads, so it has to be told they
|
||||||
@@ -2417,36 +2569,9 @@ pub fn wire<S, R, P, C>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Created inside whatever is selected, which is how a hierarchy
|
// Created inside whatever is selected, which is how a hierarchy
|
||||||
// gets built without a separate "new child" command: select the
|
// gets built without leaving the header: select the parent, press
|
||||||
// parent, press +.
|
// +. The row menu aims the same act at a row instead.
|
||||||
let parent = *ctl.scope.borrow();
|
create_child(&w, &ctl, cat, *ctl.scope.borrow());
|
||||||
let name = unique_name(cat.connection(), parent);
|
|
||||||
|
|
||||||
match coll::create(cat.connection(), &name, parent, CollectionKind::Manual) {
|
|
||||||
Ok(id) => {
|
|
||||||
w.set_collection_error(slint::SharedString::new());
|
|
||||||
// A new child is useless if its parent is collapsed.
|
|
||||||
if let Some(p) = parent {
|
|
||||||
ctl.collapsed.borrow_mut().remove(&p);
|
|
||||||
}
|
|
||||||
// Straight into the name field, with "New collection"
|
|
||||||
// selected. The name is a placeholder nobody wants to
|
|
||||||
// keep, so making the user find the rename gesture
|
|
||||||
// afterwards is asking them to finish a job we started.
|
|
||||||
//
|
|
||||||
// Opened *after* the rebuild, and the order is load-bearing:
|
|
||||||
// `refresh_tree` replaces the row model, which destroys and
|
|
||||||
// recreates every row. A field opened before it would be
|
|
||||||
// torn down along with the `init` that focuses it, leaving
|
|
||||||
// an edit box nothing had typed into. Setting the property
|
|
||||||
// afterwards puts the field on a row that already exists.
|
|
||||||
refresh_tree(&w, &ctl, cat);
|
|
||||||
*ctl.renaming.borrow_mut() = Some(id);
|
|
||||||
w.set_collection_renaming(id.0 as i32);
|
|
||||||
log::info!("created collection {} ({name})", id.0);
|
|
||||||
}
|
|
||||||
Err(e) => w.set_collection_error(format!("creating collection: {e}").into()),
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2573,44 +2698,547 @@ pub fn wire<S, R, P, C>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right-click. A real context menu needs a popup with keyboard handling and
|
// TRACES: FR-CAT-7 | FR-UI-2 | FR-UI-3 | FR-UI-4
|
||||||
// a rename field; until that exists the gesture deletes an *empty*
|
// The hold on a sidebar row: it arms the drag that rearranges the tree,
|
||||||
// collection, which is the one destructive action safe without a
|
// and it opens the row menu. Which one the user gets is decided on release
|
||||||
// confirmation dialog, and says why when it declines.
|
// by whether they moved.
|
||||||
|
//
|
||||||
|
// Both from one gesture because there is only one to spend. A finger has
|
||||||
|
// no right button, and this tree lives in a Flickable that claims any drag
|
||||||
|
// beginning inside it — so without a hold, a touch drag on a row is a
|
||||||
|
// scroll, and arranging the tree is a pointer-only feature. The row lifts
|
||||||
|
// the moment the timer fires, which is what tells the user the next
|
||||||
|
// movement will carry the collection rather than scroll past it.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
window.on_collection_row_press(move |id, down| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let id = CollectionId(id as u64);
|
||||||
|
|
||||||
|
if !down {
|
||||||
|
// Let go. A drag that began has already been dealt with by the
|
||||||
|
// drop; a hold that did not move is a request for the menu.
|
||||||
|
*ctl.row_hold_timer.borrow_mut() = None;
|
||||||
|
let held = ctl.lifted.borrow_mut().take();
|
||||||
|
let dragged = ctl.drag_began.replace(false);
|
||||||
|
w.set_collection_lifted(0);
|
||||||
|
if let Release::OpenMenu(held) = decide_release(held, dragged) {
|
||||||
|
w.invoke_collection_menu(held.0 as i32);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A drag of this row, if one follows, carries this collection.
|
||||||
|
ctl.note_row_press(id);
|
||||||
|
ctl.drag_began.set(false);
|
||||||
|
|
||||||
|
let timer = slint::Timer::default();
|
||||||
|
let weak = w.as_weak();
|
||||||
|
let ctl_cb = ctl.clone();
|
||||||
|
timer.start(
|
||||||
|
slint::TimerMode::SingleShot,
|
||||||
|
std::time::Duration::from_millis(HOLD_DELAY_MS),
|
||||||
|
move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
// Picked up, not yet acted on. The menu waits for the
|
||||||
|
// release, because opening it here would put a sheet over
|
||||||
|
// the tree the user may be about to drag this row across.
|
||||||
|
*ctl_cb.lifted.borrow_mut() = Some(id);
|
||||||
|
w.set_collection_lifted(id.0 as i32);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
*ctl.row_hold_timer.borrow_mut() = Some(timer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-CAT-7 | FR-UI-4
|
||||||
|
// A row's drag crossed the threshold, or ended.
|
||||||
|
//
|
||||||
|
// This is what turns a hold into a rearrangement: once it has fired, the
|
||||||
|
// release that follows opens no menu. It also decides whether
|
||||||
|
// "All photographs" will accept the drop, which only makes sense for a
|
||||||
|
// collection that has a parent to be taken out of.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
window.on_collection_drag_active(move |id, active| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let id = CollectionId(id as u64);
|
||||||
|
|
||||||
|
if !active {
|
||||||
|
w.set_collection_root_drop_allowed(false);
|
||||||
|
w.set_collection_lifted(0);
|
||||||
|
*ctl.lifted.borrow_mut() = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hold may not have fired — a pointer drag needs no hold — so
|
||||||
|
// the pick-up is recorded here as well as there, and the row lifts
|
||||||
|
// either way.
|
||||||
|
ctl.drag_began.set(true);
|
||||||
|
ctl.note_row_press(id);
|
||||||
|
*ctl.lifted.borrow_mut() = Some(id);
|
||||||
|
w.set_collection_lifted(id.0 as i32);
|
||||||
|
// The hold has been spent on a drag; nothing may fire behind it.
|
||||||
|
*ctl.row_hold_timer.borrow_mut() = None;
|
||||||
|
|
||||||
|
// Only a nested collection has a top level to be returned to, and
|
||||||
|
// only a collection drag — never photographs — means anything
|
||||||
|
// there at all.
|
||||||
|
let carrying_images = !ctl.dragging.borrow().is_empty();
|
||||||
|
let nested = ctl
|
||||||
|
.row_ids
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.position(|&c| c == id)
|
||||||
|
.and_then(|row| ctl.row_nested.borrow().get(row).copied())
|
||||||
|
.unwrap_or(false);
|
||||||
|
w.set_collection_root_drop_allowed(nested && !carrying_images);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-CAT-7
|
||||||
|
// Dropped on "All photographs": out to the top level.
|
||||||
|
//
|
||||||
|
// The gestural inverse of dropping one row onto another. `can-drop` has
|
||||||
|
// already refused everything but a nested collection, so reaching here
|
||||||
|
// means the drop is one — but the payload is taken rather than read, for
|
||||||
|
// the reason the row's own drop gives: a remembered press must be spent by
|
||||||
|
// the drop it belongs to, or a later empty drop moves a collection nobody
|
||||||
|
// touched.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
let catalog = catalog.clone();
|
||||||
|
window.on_collection_dropped_on_root(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let source = ctl.dragging_collection.borrow_mut().take();
|
||||||
|
let Some(source) = source else { return };
|
||||||
|
// Photographs cannot land here. Guarded as well as refused in
|
||||||
|
// `can-drop`, so a payload that somehow arrives does nothing
|
||||||
|
// rather than reparenting whatever row was last pressed.
|
||||||
|
if !ctl.dragging.borrow().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let borrow = catalog.borrow();
|
||||||
|
let Some(cat) = borrow.as_ref() else { return };
|
||||||
|
let moved = coll::set_parent(cat.connection(), source, None);
|
||||||
|
match moved {
|
||||||
|
Ok(()) => {
|
||||||
|
w.set_collection_error(slint::SharedString::new());
|
||||||
|
w.set_library_status("Moved to the top level".into());
|
||||||
|
refresh_tree(&w, &ctl, cat);
|
||||||
|
log::info!("promoted collection {} by drag", source.0);
|
||||||
|
}
|
||||||
|
Err(e) => w.set_collection_error(format!("moving collection: {e}").into()),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the row menu ------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Right-click, or a long press on touch. This gesture used to *delete an
|
||||||
|
// empty collection outright* and refuse with an error message otherwise,
|
||||||
|
// which was wrong in both directions at once: the destructive half fired
|
||||||
|
// with no confirmation and nothing on screen said it would, and the
|
||||||
|
// refusing half meant a collection holding anything could not be deleted
|
||||||
|
// at all — the user emptied it by hand and then did the same thing.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
let catalog = catalog.clone();
|
||||||
|
window.on_collection_menu(move |id| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let borrow = catalog.borrow();
|
||||||
|
let Some(cat) = borrow.as_ref() else { return };
|
||||||
|
open_row_menu(&w, &ctl, cat, CollectionId(id as u64));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename, from the menu into the row's own inline field.
|
||||||
|
//
|
||||||
|
// The menu closes first and the field opens second, and the order is
|
||||||
|
// load-bearing for the same reason it is in `collection-new`: the field is
|
||||||
|
// focused by an `init` on a row, and a row torn down by a model rebuild
|
||||||
|
// takes the focus with it. Here the tear-down is the sheet's, and a field
|
||||||
|
// opened behind a sheet that is still up is a field the user cannot see
|
||||||
|
// they are typing into.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
window.on_collection_menu_rename(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let Some(id) = *ctl.menu_for.borrow() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
close_row_menu(&w, &ctl);
|
||||||
|
*ctl.renaming.borrow_mut() = Some(id);
|
||||||
|
w.set_collection_renaming(id.0 as i32);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new collection nested inside this one.
|
||||||
|
//
|
||||||
|
// The `+` in the header already creates inside whatever the grid is scoped
|
||||||
|
// to, which builds a hierarchy only if you first go and look at the parent.
|
||||||
|
// This is the same act aimed at a row, so a tree can be built from the tree.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
let catalog = catalog.clone();
|
||||||
|
window.on_collection_menu_new_child(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let Some(parent) = *ctl.menu_for.borrow() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let borrow = catalog.borrow();
|
||||||
|
let Some(cat) = borrow.as_ref() else { return };
|
||||||
|
close_row_menu(&w, &ctl);
|
||||||
|
create_child(&w, &ctl, cat, Some(parent));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-CAT-7
|
||||||
|
// Un-nesting: back out to the top level.
|
||||||
|
//
|
||||||
|
// Nesting has had a gesture since collections landed — drag a row onto
|
||||||
|
// another row — and its inverse had none, in either direction: "All
|
||||||
|
// photographs" ignores drops on purpose (an image is already in the
|
||||||
|
// library), and no menu existed to ask. So a collection dragged into
|
||||||
|
// another was in there permanently, and the only way out was to delete it
|
||||||
|
// and build it again.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
let catalog = catalog.clone();
|
||||||
|
window.on_collection_menu_promote(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let Some(id) = *ctl.menu_for.borrow() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let borrow = catalog.borrow();
|
||||||
|
let Some(cat) = borrow.as_ref() else { return };
|
||||||
|
|
||||||
|
match coll::set_parent(cat.connection(), id, None) {
|
||||||
|
Ok(()) => {
|
||||||
|
w.set_collection_error(slint::SharedString::new());
|
||||||
|
w.set_library_status("Moved to the top level".into());
|
||||||
|
close_row_menu(&w, &ctl);
|
||||||
|
refresh_tree(&w, &ctl, cat);
|
||||||
|
log::info!("promoted collection {} to the top level", id.0);
|
||||||
|
}
|
||||||
|
// Left open on failure, showing the collection it failed on:
|
||||||
|
// closing would leave a red line referring to a row the user
|
||||||
|
// can no longer tell was the one they aimed at.
|
||||||
|
Err(e) => w.set_collection_error(format!("moving collection: {e}").into()),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-NC-6a
|
||||||
|
// The offline question, handed to the handler the tray on the row already
|
||||||
|
// uses. Routed through the window's own callback rather than reaching into
|
||||||
|
// `library_ui`'s prompt directly: that module owns the transfer state the
|
||||||
|
// prompt reads, and a second entry point into it is a second place for the
|
||||||
|
// two to disagree about what is downloading.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
window.on_collection_menu_offline(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let Some(id) = *ctl.menu_for.borrow() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
close_row_menu(&w, &ctl);
|
||||||
|
w.invoke_collection_offline_menu(id.0 as i32);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-CAT-7
|
||||||
|
// Delete, in one press or two — see [`decide_delete`].
|
||||||
{
|
{
|
||||||
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 reload = on_scope_changed.clone();
|
let reload = on_scope_changed.clone();
|
||||||
window.on_collection_menu(move |id| {
|
let visible = visible_ids.clone();
|
||||||
|
window.on_collection_menu_delete(move || {
|
||||||
let Some(w) = weak.upgrade() else { return };
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let Some(id) = *ctl.menu_for.borrow() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let borrow = catalog.borrow();
|
let borrow = catalog.borrow();
|
||||||
let Some(cat) = borrow.as_ref() else { return };
|
let Some(cat) = borrow.as_ref() else { return };
|
||||||
let id = CollectionId(id as u64);
|
|
||||||
|
|
||||||
let holds = coll::deep_count(cat.connection(), id).unwrap_or(1);
|
let (holds, children) = direct_holdings(cat.connection(), id).unwrap_or((0, 0));
|
||||||
if holds > 0 {
|
if decide_delete(holds, children, ctl.menu_confirming.get()) == DeleteStep::Confirm {
|
||||||
w.set_collection_error(
|
// Asked once. The sheet swaps its other actions for the
|
||||||
format!("{holds} photograph(s) in there — empty it first.").into(),
|
// warning, so the second press cannot be a mis-aimed first one.
|
||||||
);
|
ctl.menu_confirming.set(true);
|
||||||
|
w.set_collection_menu_confirming(true);
|
||||||
|
w.set_collection_menu_delete_label("Delete anyway".into());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
match coll::delete(cat.connection(), id) {
|
// Hoisted out of the `match` rather than called in the scrutinee:
|
||||||
|
// the borrow `cat` comes from lives as long as the match does, and
|
||||||
|
// the arm below has to release it before rereading the grid.
|
||||||
|
let deleted = coll::delete(cat.connection(), id);
|
||||||
|
match deleted {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
w.set_collection_error(slint::SharedString::new());
|
w.set_collection_error(slint::SharedString::new());
|
||||||
if *ctl.scope.borrow() == Some(id) {
|
close_row_menu(&w, &ctl);
|
||||||
|
|
||||||
|
// The grid was showing what no longer exists.
|
||||||
|
let was_scope = *ctl.scope.borrow() == Some(id);
|
||||||
|
if was_scope {
|
||||||
*ctl.scope.borrow_mut() = None;
|
*ctl.scope.borrow_mut() = None;
|
||||||
w.set_collection_selected(0);
|
w.set_collection_selected(0);
|
||||||
w.set_collection_scope_label(slint::SharedString::new());
|
w.set_collection_scope_label(slint::SharedString::new());
|
||||||
reload();
|
|
||||||
}
|
}
|
||||||
refresh_tree(&w, &ctl, cat);
|
refresh_tree(&w, &ctl, cat);
|
||||||
|
// Named as a membership change, as the removal button is:
|
||||||
|
// a user who reads this as a delete of their photographs
|
||||||
|
// will not trust the feature again.
|
||||||
|
w.set_library_status(if holds > 0 {
|
||||||
|
format!(
|
||||||
|
"Deleted the collection; its {holds} photograph(s) stay in the library"
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
slint::SharedString::from("Deleted the collection")
|
||||||
|
});
|
||||||
|
log::info!("deleted collection {}", id.0);
|
||||||
|
|
||||||
|
// The badge on every visible cell just lost a collection,
|
||||||
|
// and a scope that has gone needs the grid rereading.
|
||||||
|
let cells = visible();
|
||||||
|
sync_badges(&w, cat, &cells);
|
||||||
|
drop(borrow);
|
||||||
|
if was_scope {
|
||||||
|
reload();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => w.set_collection_error(format!("deleting: {e}").into()),
|
Err(e) => w.set_collection_error(format!("deleting: {e}").into()),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dismiss. A pending confirmation is backed out of one step rather than
|
||||||
|
// closing the whole menu: Escape from "are you sure" means "no", and
|
||||||
|
// taking the sheet away with it would leave the user unsure whether the
|
||||||
|
// key had cancelled the delete or performed it.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
window.on_collection_menu_dismiss(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
if ctl.menu_confirming.get() {
|
||||||
|
ctl.menu_confirming.set(false);
|
||||||
|
w.set_collection_menu_confirming(false);
|
||||||
|
w.set_collection_menu_delete_label("Delete".into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
close_row_menu(&w, &ctl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- where the selection is filed --------------------------------------
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
let catalog = catalog.clone();
|
||||||
|
window.on_library_open_membership(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let borrow = catalog.borrow();
|
||||||
|
let Some(cat) = borrow.as_ref() else { return };
|
||||||
|
refresh_membership(&w, &ctl, cat);
|
||||||
|
w.set_membership_open(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
let catalog = catalog.clone();
|
||||||
|
let visible = visible_ids.clone();
|
||||||
|
let reload = on_scope_changed.clone();
|
||||||
|
window.on_membership_remove(move |id| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let target = CollectionId(id as u64);
|
||||||
|
let chosen = ctl.selected();
|
||||||
|
if chosen.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let borrow = catalog.borrow();
|
||||||
|
let Some(cat) = borrow.as_ref() else { return };
|
||||||
|
|
||||||
|
// Hoisted for the same reason the delete above is: the arm
|
||||||
|
// releases the catalog borrow before it rereads the grid.
|
||||||
|
let removed = coll::remove_images(cat.connection(), target, &chosen);
|
||||||
|
match removed {
|
||||||
|
Ok(n) => {
|
||||||
|
w.set_collection_error(slint::SharedString::new());
|
||||||
|
// The same wording the scoped button uses, and for the same
|
||||||
|
// reason: this is a membership change, not a delete.
|
||||||
|
w.set_library_status(
|
||||||
|
format!("Removed {n} from that collection; still in the library").into(),
|
||||||
|
);
|
||||||
|
// The sheet stays open. Taking photographs out of several
|
||||||
|
// collections is one job, and a sheet that closed after
|
||||||
|
// each would have to be reopened — with the selection
|
||||||
|
// still live — to finish it.
|
||||||
|
refresh_membership(&w, &ctl, cat);
|
||||||
|
refresh_tree(&w, &ctl, cat);
|
||||||
|
let cells = visible();
|
||||||
|
sync_badges(&w, cat, &cells);
|
||||||
|
// Only the collection on screen changes what the grid
|
||||||
|
// holds. Removing from another one leaves the grid right,
|
||||||
|
// and rereading it would scroll the user's place away.
|
||||||
|
let showing = *ctl.scope.borrow() == Some(target);
|
||||||
|
drop(borrow);
|
||||||
|
if showing {
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => w.set_collection_error(format!("removing: {e}").into()),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
window.on_membership_dismiss(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
w.set_membership_open(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-7
|
||||||
|
/// What a collection directly holds: its own member rows, and its own children.
|
||||||
|
///
|
||||||
|
/// One query each rather than reusing `deep_count`, which counts descendants'
|
||||||
|
/// photographs — see [`menu_detail`] for why the menu needs the direct numbers.
|
||||||
|
fn direct_holdings(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
id: CollectionId,
|
||||||
|
) -> Result<(usize, usize), dr_catalog::CatalogError> {
|
||||||
|
let holds: i64 = conn.query_row(
|
||||||
|
"SELECT count(*) FROM collection_members WHERE collection_id = ?1",
|
||||||
|
[id.0 as i64],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
let children: i64 = conn.query_row(
|
||||||
|
"SELECT count(*) FROM collections WHERE parent_id = ?1 AND deleted = 0",
|
||||||
|
[id.0 as i64],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
Ok((holds as usize, children as usize))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-7
|
||||||
|
/// Open the row menu on `id`, or leave it shut if the collection is gone.
|
||||||
|
///
|
||||||
|
/// Everything the sheet draws is computed here, in one place, so the menu
|
||||||
|
/// cannot offer "Move to top level" on a collection already at the top, or a
|
||||||
|
/// delete warning about children it no longer has. Reopened from scratch after
|
||||||
|
/// every action for the same reason.
|
||||||
|
fn open_row_menu(
|
||||||
|
window: &AppWindow,
|
||||||
|
ctl: &Rc<CollectionsController>,
|
||||||
|
catalog: &Catalog,
|
||||||
|
id: CollectionId,
|
||||||
|
) {
|
||||||
|
let conn = catalog.connection();
|
||||||
|
let row: Option<(String, Option<i64>, i64)> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT name, parent_id, kind FROM collections WHERE id = ?1 AND deleted = 0",
|
||||||
|
[id.0 as i64],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.unwrap_or(None);
|
||||||
|
let Some((name, parent, kind)) = row else {
|
||||||
|
// Deleted underneath us — by a merge, or by the action that just ran.
|
||||||
|
close_row_menu(window, ctl);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (holds, children) = direct_holdings(conn, id).unwrap_or((0, 0));
|
||||||
|
|
||||||
|
*ctl.menu_for.borrow_mut() = Some(id);
|
||||||
|
ctl.menu_confirming.set(false);
|
||||||
|
window.set_collection_menu_confirming(false);
|
||||||
|
window.set_collection_menu_title(name.as_str().into());
|
||||||
|
window.set_collection_menu_detail(menu_detail(holds, children).into());
|
||||||
|
window.set_collection_menu_nested(parent.is_some());
|
||||||
|
window.set_collection_menu_smart(kind == CollectionKind::Smart as i64);
|
||||||
|
window.set_collection_menu_confirm_detail(delete_warning(&name, holds, children).into());
|
||||||
|
window.set_collection_menu_delete_label("Delete".into());
|
||||||
|
|
||||||
|
// TRACES: FR-NC-6a
|
||||||
|
// Read off the row the sidebar already drew rather than recomputed: the
|
||||||
|
// roll-up is one pass over the whole tree, and a menu that computed its own
|
||||||
|
// could disagree with the tray beside the name it is titled with.
|
||||||
|
let pinned = ctl
|
||||||
|
.row_ids
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.position(|&c| c == id)
|
||||||
|
.and_then(|row| window.get_collection_rows().row_data(row))
|
||||||
|
.is_some_and(|r| r.pinned);
|
||||||
|
window.set_collection_menu_pinned(pinned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shut the row menu, both halves.
|
||||||
|
///
|
||||||
|
/// The controller's copy is what the handlers act on and the window property is
|
||||||
|
/// what draws the sheet; clearing one without the other leaves either an
|
||||||
|
/// invisible menu that still answers, or a visible one that acts on nothing.
|
||||||
|
fn close_row_menu(window: &AppWindow, ctl: &Rc<CollectionsController>) {
|
||||||
|
*ctl.menu_for.borrow_mut() = None;
|
||||||
|
ctl.menu_confirming.set(false);
|
||||||
|
window.set_collection_menu_title(slint::SharedString::new());
|
||||||
|
window.set_collection_menu_confirming(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||||
|
/// Rebuild the membership sheet from the selection as it stands.
|
||||||
|
///
|
||||||
|
/// Called on open and after every removal rather than patched: a removal can
|
||||||
|
/// take the last selected photograph out of a collection, and that row then has
|
||||||
|
/// to go — a patched model would leave a "Remove" that removes nothing.
|
||||||
|
fn refresh_membership(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog: &Catalog) {
|
||||||
|
let chosen = ctl.selected();
|
||||||
|
let total = chosen.len();
|
||||||
|
let rows = match coll::membership_of(catalog.connection(), &chosen) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
window.set_collection_error(format!("reading collections: {e}").into());
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let out: Vec<crate::MembershipRow> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| crate::MembershipRow {
|
||||||
|
id: m.id.0 as i32,
|
||||||
|
name: m.name.as_str().into(),
|
||||||
|
holding: m.holding as i32,
|
||||||
|
// "1 of 1" is arithmetic nobody asked for; with one photograph
|
||||||
|
// selected the name already says everything true about it.
|
||||||
|
detail: if total <= 1 {
|
||||||
|
slint::SharedString::new()
|
||||||
|
} else {
|
||||||
|
format!("{} of {total}", m.holding).into()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
window.set_membership_rows(slint::ModelRc::new(slint::VecModel::from(out)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Close the rename field, whatever the outcome.
|
/// Close the rename field, whatever the outcome.
|
||||||
@@ -2697,6 +3325,48 @@ fn apply_rename(
|
|||||||
Ok(Rename::Applied(name.to_string()))
|
Ok(Rename::Applied(name.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-7
|
||||||
|
/// Make a collection inside `parent` and open its name field.
|
||||||
|
///
|
||||||
|
/// Shared by the header's `+` and the row menu's "New collection inside",
|
||||||
|
/// which differ only in where the parent comes from — and must not differ in
|
||||||
|
/// anything else, or building a tree from the tree would behave unlike
|
||||||
|
/// building one from the header.
|
||||||
|
fn create_child(
|
||||||
|
window: &AppWindow,
|
||||||
|
ctl: &Rc<CollectionsController>,
|
||||||
|
catalog: &Catalog,
|
||||||
|
parent: Option<CollectionId>,
|
||||||
|
) {
|
||||||
|
let name = unique_name(catalog.connection(), parent);
|
||||||
|
|
||||||
|
match coll::create(catalog.connection(), &name, parent, CollectionKind::Manual) {
|
||||||
|
Ok(id) => {
|
||||||
|
window.set_collection_error(slint::SharedString::new());
|
||||||
|
// A new child is useless if its parent is collapsed.
|
||||||
|
if let Some(p) = parent {
|
||||||
|
ctl.collapsed.borrow_mut().remove(&p);
|
||||||
|
}
|
||||||
|
// Straight into the name field, with "New collection" selected.
|
||||||
|
// The name is a placeholder nobody wants to keep, so making the
|
||||||
|
// user find the rename gesture afterwards is asking them to finish
|
||||||
|
// a job we started.
|
||||||
|
//
|
||||||
|
// Opened *after* the rebuild, and the order is load-bearing:
|
||||||
|
// `refresh_tree` replaces the row model, which destroys and
|
||||||
|
// recreates every row. A field opened before it would be torn down
|
||||||
|
// along with the `init` that focuses it, leaving an edit box
|
||||||
|
// nothing had typed into. Setting the property afterwards puts the
|
||||||
|
// field on a row that already exists.
|
||||||
|
refresh_tree(window, ctl, catalog);
|
||||||
|
*ctl.renaming.borrow_mut() = Some(id);
|
||||||
|
window.set_collection_renaming(id.0 as i32);
|
||||||
|
log::info!("created collection {} ({name})", id.0);
|
||||||
|
}
|
||||||
|
Err(e) => window.set_collection_error(format!("creating collection: {e}").into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A name no sibling is already using.
|
/// A name no sibling is already using.
|
||||||
///
|
///
|
||||||
/// Duplicate names are legal in the schema, and two identically-named
|
/// Duplicate names are legal in the schema, and two identically-named
|
||||||
@@ -3719,4 +4389,84 @@ mod tests {
|
|||||||
let top = coll::create(c, "New collection", None, CollectionKind::Manual).unwrap();
|
let top = coll::create(c, "New collection", None, CollectionKind::Manual).unwrap();
|
||||||
assert_eq!(unique_name(c, Some(top)), "New collection");
|
assert_eq!(unique_name(c, Some(top)), "New collection");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_collection_is_deleted_without_being_asked_about() {
|
||||||
|
// There is nothing to warn about, and a dialogue asking "delete this
|
||||||
|
// empty thing?" is the kind that teaches people to dismiss dialogues
|
||||||
|
// without reading them — including the one that mattered.
|
||||||
|
assert_eq!(decide_delete(0, 0, false), DeleteStep::Now);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_collection_holding_anything_is_asked_about_once() {
|
||||||
|
assert_eq!(decide_delete(12, 0, false), DeleteStep::Confirm);
|
||||||
|
assert_eq!(decide_delete(0, 1, false), DeleteStep::Confirm);
|
||||||
|
// And once asked, it goes.
|
||||||
|
assert_eq!(decide_delete(12, 3, true), DeleteStep::Now);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_collection_holding_only_children_still_asks() {
|
||||||
|
// The old gesture refused on `deep_count > 0`, which counted
|
||||||
|
// descendants' photographs — so an empty parent of empty children was
|
||||||
|
// deletable in one press and an empty parent of a *full* child was not
|
||||||
|
// deletable at all. Both are now the same question, asked once.
|
||||||
|
assert_eq!(decide_delete(0, 2, false), DeleteStep::Confirm);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_menu_line_counts_what_delete_acts_on() {
|
||||||
|
assert_eq!(menu_detail(0, 0), "no photographs");
|
||||||
|
assert_eq!(menu_detail(1, 0), "1 photograph");
|
||||||
|
assert_eq!(menu_detail(12, 1), "12 photographs · 1 collection inside");
|
||||||
|
assert_eq!(menu_detail(0, 3), "no photographs · 3 collections inside");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_warning_says_the_photographs_survive() {
|
||||||
|
// The single most likely misreading of "Delete" here. If this clause
|
||||||
|
// ever goes, a user deletes a collection expecting to lose the
|
||||||
|
// pictures — and either panics, or does not do it at all.
|
||||||
|
let w = delete_warning("Iceland", 12, 0);
|
||||||
|
assert!(w.contains("12 photographs"), "{w}");
|
||||||
|
assert!(w.contains("stay in your library"), "{w}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_warning_says_nested_collections_are_promoted_not_taken() {
|
||||||
|
// `dr_catalog::collections::delete` promotes children to the deleted
|
||||||
|
// collection's parent rather than cascading. A warning that did not
|
||||||
|
// say so would describe a data loss that does not happen.
|
||||||
|
let w = delete_warning("Trips", 0, 2);
|
||||||
|
assert!(w.contains("2 collections inside it move up"), "{w}");
|
||||||
|
// And nothing about photographs surviving, because none were lost.
|
||||||
|
assert!(!w.contains("stay in your library"), "{w}");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn a_hold_that_did_not_move_opens_the_menu() {
|
||||||
|
assert_eq!(
|
||||||
|
decide_release(Some(CollectionId(4)), false),
|
||||||
|
Release::OpenMenu(CollectionId(4))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hold_that_became_a_drag_opens_nothing() {
|
||||||
|
// The drop has already done the work. A menu here would put a sheet
|
||||||
|
// over the tree the user has just finished rearranging — and over the
|
||||||
|
// row they would have to look at to see whether it worked.
|
||||||
|
assert_eq!(
|
||||||
|
decide_release(Some(CollectionId(4)), true),
|
||||||
|
Release::Nothing
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_ordinary_tap_opens_nothing() {
|
||||||
|
// Nothing was ever picked up, so this is the press that selects a
|
||||||
|
// collection and scopes the grid. A menu on every tap would make the
|
||||||
|
// sidebar unusable.
|
||||||
|
assert_eq!(decide_release(None, false), Release::Nothing);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,34 @@ pub const GESTURES: &[Gesture] = &[
|
|||||||
pointer: "Click the ring at the head of its row",
|
pointer: "Click the ring at the head of its row",
|
||||||
keys: "",
|
keys: "",
|
||||||
},
|
},
|
||||||
|
Gesture {
|
||||||
|
title: "Pick a collection up to rearrange the tree",
|
||||||
|
section: "Collections sidebar",
|
||||||
|
touch: "Press and hold it until it lifts, then drag it",
|
||||||
|
pointer: "Drag it, or hold it until it lifts and then drag",
|
||||||
|
keys: "",
|
||||||
|
},
|
||||||
|
Gesture {
|
||||||
|
title: "Act on a collection — rename, nest, un-nest, delete",
|
||||||
|
section: "Collections sidebar",
|
||||||
|
touch: "Press and hold the collection, then let go without moving",
|
||||||
|
pointer: "Right-click it",
|
||||||
|
keys: "",
|
||||||
|
},
|
||||||
|
Gesture {
|
||||||
|
title: "Take a collection back out of the one it is nested in",
|
||||||
|
section: "Collections sidebar",
|
||||||
|
touch: "Hold it, then drag it onto \"All photographs\" — or let go and choose \"Move to top level\"",
|
||||||
|
pointer: "Drag it onto \"All photographs\", or right-click it and choose \"Move to top level\"",
|
||||||
|
keys: "",
|
||||||
|
},
|
||||||
|
Gesture {
|
||||||
|
title: "Rename a collection",
|
||||||
|
section: "Collections sidebar",
|
||||||
|
touch: "Hold the collection, then \"Rename\"",
|
||||||
|
pointer: "Double-click its name, or right-click it and choose \"Rename\"",
|
||||||
|
keys: "",
|
||||||
|
},
|
||||||
Gesture {
|
Gesture {
|
||||||
title: "Pull a face out of the wrong person",
|
title: "Pull a face out of the wrong person",
|
||||||
section: "People",
|
section: "People",
|
||||||
@@ -232,4 +260,11 @@ pub const GESTURES: &[Gesture] = &[
|
|||||||
pointer: "While selecting, press \"Select all\"",
|
pointer: "While selecting, press \"Select all\"",
|
||||||
keys: "",
|
keys: "",
|
||||||
},
|
},
|
||||||
|
Gesture {
|
||||||
|
title: "Take photographs out of a collection",
|
||||||
|
section: "Library grid",
|
||||||
|
touch: "Select them, then \"Collections…\" in the selection bar",
|
||||||
|
pointer: "Select them, then \"Collections…\" in the selection bar",
|
||||||
|
keys: "",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -357,11 +357,6 @@ pub struct LibraryController {
|
|||||||
/// subject back out of a string property would act on whatever the sidebar
|
/// subject back out of a string property would act on whatever the sidebar
|
||||||
/// had been rebuilt to say since.
|
/// had been rebuilt to say since.
|
||||||
offline_target: std::cell::Cell<Option<dr_types::CollectionId>>,
|
offline_target: std::cell::Cell<Option<dr_types::CollectionId>>,
|
||||||
/// TRACES: FR-NC-6a | FR-UI-2
|
|
||||||
/// The timer that turns a held sidebar row into that question. Dropped on
|
|
||||||
/// release, so a tap — or a press the Flickable takes for a scroll — is not
|
|
||||||
/// a dialogue a moment later.
|
|
||||||
row_hold_timer: RefCell<Option<slint::Timer>>,
|
|
||||||
/// Narrow the grid to images whose original is stored locally.
|
/// Narrow the grid to images whose original is stored locally.
|
||||||
///
|
///
|
||||||
/// A `Cell` beside `filter` rather than a field inside it: the rating
|
/// A `Cell` beside `filter` rather than a field inside it: the rating
|
||||||
@@ -455,7 +450,6 @@ impl LibraryController {
|
|||||||
pending_anchor: std::cell::Cell::new(None),
|
pending_anchor: std::cell::Cell::new(None),
|
||||||
pin_timer: RefCell::new(None),
|
pin_timer: RefCell::new(None),
|
||||||
offline_target: std::cell::Cell::new(None),
|
offline_target: std::cell::Cell::new(None),
|
||||||
row_hold_timer: RefCell::new(None),
|
|
||||||
local_only: std::cell::Cell::new(false),
|
local_only: std::cell::Cell::new(false),
|
||||||
// The catalog's own floor until the settings page reports what the
|
// The catalog's own floor until the settings page reports what the
|
||||||
// user has stored, which it does at startup before any fetch.
|
// user has stored, which it does at startup before any fetch.
|
||||||
@@ -6594,48 +6588,15 @@ pub fn wire<F>(
|
|||||||
let ctl = ctl.clone();
|
let ctl = ctl.clone();
|
||||||
window.on_collection_offline_menu(move |id| {
|
window.on_collection_offline_menu(move |id| {
|
||||||
let Some(w) = weak.upgrade() else { return };
|
let Some(w) = weak.upgrade() else { return };
|
||||||
*ctl.row_hold_timer.borrow_mut() = None;
|
|
||||||
open_offline_prompt(&w, &ctl, dr_types::CollectionId(id as u64));
|
open_offline_prompt(&w, &ctl, dr_types::CollectionId(id as u64));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRACES: FR-NC-6a | FR-UI-2 | FR-UI-4
|
// The hold on a sidebar row — which arms the drag that rearranges the tree
|
||||||
// And the touch way in: hold the collection's name.
|
// and opens the row menu — is wired in `collections_ui`, with the rest of
|
||||||
//
|
// what a collection row does. It used to be here because it opened the
|
||||||
// The release that ends the hold still reaches the row's `clicked` and
|
// offline question and nothing else; that question is now one item in that
|
||||||
// scopes the grid to that collection. Left deliberately: the user is now
|
// menu, so the gesture belongs with the menu rather than with the transfers.
|
||||||
// looking at the photographs they are being asked about, which is context
|
|
||||||
// rather than a side effect — and suppressing it would mean a second
|
|
||||||
// "ignore the next click" flag threaded through the sidebar for no gain.
|
|
||||||
{
|
|
||||||
let weak = window.as_weak();
|
|
||||||
let ctl = ctl.clone();
|
|
||||||
let coll_for_press = coll_ctl.clone();
|
|
||||||
window.on_collection_row_press(move |id, down| {
|
|
||||||
let Some(w) = weak.upgrade() else { return };
|
|
||||||
// A drag of this row, if one follows, carries this collection.
|
|
||||||
if down {
|
|
||||||
coll_for_press.note_row_press(dr_types::CollectionId(id as u64));
|
|
||||||
}
|
|
||||||
if !down {
|
|
||||||
*ctl.row_hold_timer.borrow_mut() = None;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let timer = slint::Timer::default();
|
|
||||||
let weak = w.as_weak();
|
|
||||||
let ctl_cb = ctl.clone();
|
|
||||||
timer.start(
|
|
||||||
slint::TimerMode::SingleShot,
|
|
||||||
std::time::Duration::from_millis(crate::collections_ui::HOLD_DELAY_MS),
|
|
||||||
move || {
|
|
||||||
let Some(w) = weak.upgrade() else { return };
|
|
||||||
open_offline_prompt(&w, &ctl_cb, dr_types::CollectionId(id as u64));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
*ctl.row_hold_timer.borrow_mut() = Some(timer);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// The three answers.
|
// The three answers.
|
||||||
{
|
{
|
||||||
|
|||||||
+113
-1
@@ -9,7 +9,8 @@ import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.slint";
|
|||||||
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChip } from "library.slint";
|
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChip } from "library.slint";
|
||||||
import { GestureRow } from "gestures.slint";
|
import { GestureRow } from "gestures.slint";
|
||||||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
||||||
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
|
import { CollectionsPanel, CollectionRow, OfflinePrompt, CollectionMenu,
|
||||||
|
MembershipSheet, MembershipRow } from "collections.slint";
|
||||||
import { HistogramPanel, HistogramView } from "histogram.slint";
|
import { HistogramPanel, HistogramView } from "histogram.slint";
|
||||||
import { RecoveryPrompt } from "recovery.slint";
|
import { RecoveryPrompt } from "recovery.slint";
|
||||||
import { PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
|
import { PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
|
||||||
@@ -619,12 +620,55 @@ export component AppWindow inherits Window {
|
|||||||
callback collection-toggle(int);
|
callback collection-toggle(int);
|
||||||
callback collection-new();
|
callback collection-new();
|
||||||
callback collection-menu(int);
|
callback collection-menu(int);
|
||||||
|
|
||||||
|
// --- the row menu the gesture above opens (FR-CAT-7) -------------------
|
||||||
|
//
|
||||||
|
// Rust owns every one of these. What the menu offers depends on counts it
|
||||||
|
// has to query — whether the collection is nested, what deleting would
|
||||||
|
// take — and the confirmation step is a policy decision, not a local bit
|
||||||
|
// the sheet can flip for itself.
|
||||||
|
/// The collection the menu is about. Empty closes it.
|
||||||
|
in property <string> collection-menu-title;
|
||||||
|
/// "12 photographs · 2 collections inside".
|
||||||
|
in property <string> collection-menu-detail;
|
||||||
|
/// Whether it has a parent, and so somewhere to be promoted out to.
|
||||||
|
in property <bool> collection-menu-nested: false;
|
||||||
|
/// Whether it is a saved filter, which cannot hold a nested collection.
|
||||||
|
in property <bool> collection-menu-smart: false;
|
||||||
|
/// Whether a delete has been asked once and awaits confirmation.
|
||||||
|
in property <bool> collection-menu-confirming: false;
|
||||||
|
/// What the delete would take with it, and what survives it.
|
||||||
|
in property <string> collection-menu-confirm-detail;
|
||||||
|
in property <string> collection-menu-delete-label;
|
||||||
|
/// TRACES: FR-NC-6a
|
||||||
|
/// Whether the collection the menu is about is kept on this device.
|
||||||
|
in property <bool> collection-menu-pinned: false;
|
||||||
|
callback collection-menu-rename();
|
||||||
|
callback collection-menu-new-child();
|
||||||
|
/// Move it back to the top level. The inverse of the drag that nests one,
|
||||||
|
/// which until now had no gesture at all.
|
||||||
|
callback collection-menu-promote();
|
||||||
|
/// TRACES: FR-NC-6a
|
||||||
|
/// Hand the offline question over to the handler the tray on the row
|
||||||
|
/// already uses, rather than a second route to the same prompt.
|
||||||
|
callback collection-menu-offline();
|
||||||
|
callback collection-menu-delete();
|
||||||
|
callback collection-menu-dismiss();
|
||||||
/// TRACES: FR-NC-6a
|
/// TRACES: FR-NC-6a
|
||||||
/// Ask about a collection's local copies: the tray on a row, or a long
|
/// Ask about a collection's local copies: the tray on a row, or a long
|
||||||
/// press anywhere on it.
|
/// press anywhere on it.
|
||||||
callback collection-offline-menu(int);
|
callback collection-offline-menu(int);
|
||||||
/// A row is held or released — the long press is timed in Rust.
|
/// A row is held or released — the long press is timed in Rust.
|
||||||
callback collection-row-press(int, bool);
|
callback collection-row-press(int, bool);
|
||||||
|
// --- picking a collection up (FR-UI-3, FR-UI-4) ------------------------
|
||||||
|
/// Which collection the hold has picked up, by id. 0 is none.
|
||||||
|
in property <int> collection-lifted: 0;
|
||||||
|
/// A row's drag crossed the threshold, or ended.
|
||||||
|
callback collection-drag-active(int, bool);
|
||||||
|
/// Whether "All photographs" would take the drag currently in flight.
|
||||||
|
in property <bool> collection-root-drop-allowed: false;
|
||||||
|
/// A collection was dropped on "All photographs": out to the top level.
|
||||||
|
callback collection-dropped-on-root();
|
||||||
/// TRACES: FR-CAT-7 | FR-UI-4
|
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||||
/// File the grid's selection in a collection without dragging it there:
|
/// File the grid's selection in a collection without dragging it there:
|
||||||
/// the target's id, and whether to take the images out of the collection
|
/// the target's id, and whether to take the images out of the collection
|
||||||
@@ -717,6 +761,18 @@ export component AppWindow inherits Window {
|
|||||||
callback library-cell-pressed(int, bool, bool);
|
callback library-cell-pressed(int, bool, bool);
|
||||||
callback library-remove-from-collection();
|
callback library-remove-from-collection();
|
||||||
|
|
||||||
|
// --- where the selection is filed (FR-CAT-7) ---------------------------
|
||||||
|
//
|
||||||
|
// `library-remove-from-collection` can only ever mean the collection the
|
||||||
|
// grid is scoped to — it is the only one that button can name. This sheet
|
||||||
|
// is the other half: which collections the selection is actually in, and
|
||||||
|
// the way out of any of them without navigating there first.
|
||||||
|
in property <bool> membership-open: false;
|
||||||
|
in property <[MembershipRow]> membership-rows;
|
||||||
|
callback library-open-membership();
|
||||||
|
callback membership-remove(int);
|
||||||
|
callback membership-dismiss();
|
||||||
|
|
||||||
// The keyboard cursor: where the arrows are in the library, as an image
|
// The keyboard cursor: where the arrows are in the library, as an image
|
||||||
// ordinal. Rust owns it — clamping it needs the library's length, and
|
// ordinal. Rust owns it — clamping it needs the library's length, and
|
||||||
// moving it may have to swap the loaded window underneath.
|
// moving it may have to swap the loaded window underneath.
|
||||||
@@ -1298,6 +1354,19 @@ in property <bool> panel-visible: true;
|
|||||||
root.recovery-dismiss();
|
root.recovery-dismiss();
|
||||||
return accept;
|
return accept;
|
||||||
}
|
}
|
||||||
|
// The row menu and the membership sheet are drawn over the
|
||||||
|
// offline prompt, so Back has to reach them first — and the
|
||||||
|
// menu's own confirmation is a step Back must be able to take
|
||||||
|
// one at a time, which is why the dismiss handlers in Rust
|
||||||
|
// back out of a pending delete rather than closing outright.
|
||||||
|
if (root.collection-menu-title != "") {
|
||||||
|
root.collection-menu-dismiss();
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
|
if (root.membership-open) {
|
||||||
|
root.membership-dismiss();
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
if (root.offline-prompt-title != "") {
|
if (root.offline-prompt-title != "") {
|
||||||
root.offline-prompt-dismiss();
|
root.offline-prompt-dismiss();
|
||||||
return accept;
|
return accept;
|
||||||
@@ -1610,6 +1679,10 @@ in property <bool> panel-visible: true;
|
|||||||
row-menu(id) => { root.collection-menu(id); }
|
row-menu(id) => { root.collection-menu(id); }
|
||||||
offline-menu(id) => { root.collection-offline-menu(id); }
|
offline-menu(id) => { root.collection-offline-menu(id); }
|
||||||
row-press(id, down) => { root.collection-row-press(id, down); }
|
row-press(id, down) => { root.collection-row-press(id, down); }
|
||||||
|
lifted-id: root.collection-lifted;
|
||||||
|
drag-active(id, on) => { root.collection-drag-active(id, on); }
|
||||||
|
root-drop-allowed: root.collection-root-drop-allowed;
|
||||||
|
dropped-on-root() => { root.collection-dropped-on-root(); }
|
||||||
|
|
||||||
renaming-id: root.collection-renaming;
|
renaming-id: root.collection-renaming;
|
||||||
rename-start(id) => { root.collection-rename-start(id); }
|
rename-start(id) => { root.collection-rename-start(id); }
|
||||||
@@ -1766,6 +1839,7 @@ in property <bool> panel-visible: true;
|
|||||||
remove-from-collection() => {
|
remove-from-collection() => {
|
||||||
root.library-remove-from-collection();
|
root.library-remove-from-collection();
|
||||||
}
|
}
|
||||||
|
open-membership() => { root.library-open-membership(); }
|
||||||
|
|
||||||
filter-people: root.library-filter-people;
|
filter-people: root.library-filter-people;
|
||||||
filter-people-all: root.library-filter-people-all;
|
filter-people-all: root.library-filter-people-all;
|
||||||
@@ -3194,6 +3268,44 @@ in property <bool> panel-visible: true;
|
|||||||
dismiss() => { root.offline-prompt-dismiss(); }
|
dismiss() => { root.offline-prompt-dismiss(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-CAT-7 | FR-UI-4
|
||||||
|
// The row menu and the membership sheet, above the offline prompt for
|
||||||
|
// the same reason it sits above the shell: both are opened from a
|
||||||
|
// 232px sidebar that disappears entirely in the compact layout, so
|
||||||
|
// neither can be drawn inside the thing that raises it.
|
||||||
|
//
|
||||||
|
// The two never coexist — one is about a collection, the other about a
|
||||||
|
// selection — but they are laid out in the order Back unwinds them,
|
||||||
|
// which is the order they are checked in above.
|
||||||
|
MembershipSheet {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
open: root.membership-open;
|
||||||
|
selected-count: root.library-selected-count;
|
||||||
|
rows: root.membership-rows;
|
||||||
|
remove(id) => { root.membership-remove(id); }
|
||||||
|
dismiss() => { root.membership-dismiss(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
CollectionMenu {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
title: root.collection-menu-title;
|
||||||
|
detail: root.collection-menu-detail;
|
||||||
|
nested: root.collection-menu-nested;
|
||||||
|
smart: root.collection-menu-smart;
|
||||||
|
confirming: root.collection-menu-confirming;
|
||||||
|
confirm-detail: root.collection-menu-confirm-detail;
|
||||||
|
delete-label: root.collection-menu-delete-label;
|
||||||
|
pinned: root.collection-menu-pinned;
|
||||||
|
rename() => { root.collection-menu-rename(); }
|
||||||
|
new-child() => { root.collection-menu-new-child(); }
|
||||||
|
promote() => { root.collection-menu-promote(); }
|
||||||
|
offline() => { root.collection-menu-offline(); }
|
||||||
|
delete() => { root.collection-menu-delete(); }
|
||||||
|
dismiss() => { root.collection-menu-dismiss(); }
|
||||||
|
}
|
||||||
|
|
||||||
// Last, and therefore over everything including the settings page and
|
// Last, and therefore over everything including the settings page and
|
||||||
// the offline prompt. Not a preference about layering: this is asked
|
// the offline prompt. Not a preference about layering: this is asked
|
||||||
// before the grid exists, and nothing else in the window is about a
|
// before the grid exists, and nothing else in the window is about a
|
||||||
|
|||||||
+447
-17
@@ -72,6 +72,13 @@ component TreeRow inherits Rectangle {
|
|||||||
/// Whether this row is being renamed, in which case its name is drawn as an
|
/// Whether this row is being renamed, in which case its name is drawn as an
|
||||||
/// editable field rather than as text.
|
/// editable field rather than as text.
|
||||||
in property <bool> renaming: false;
|
in property <bool> renaming: false;
|
||||||
|
/// TRACES: FR-UI-3 | FR-UI-4
|
||||||
|
/// Picked up by a hold, and now the thing a movement would carry.
|
||||||
|
///
|
||||||
|
/// Drawn before any movement happens, which is the whole point: the hold
|
||||||
|
/// has committed the next drag to *rearranging the tree* rather than
|
||||||
|
/// scrolling it, and nothing else on screen would say so.
|
||||||
|
in property <bool> lifted: false;
|
||||||
|
|
||||||
callback clicked();
|
callback clicked();
|
||||||
callback toggle();
|
callback toggle();
|
||||||
@@ -105,15 +112,20 @@ component TreeRow inherits Rectangle {
|
|||||||
// row never lights up as though it would take the images.
|
// row never lights up as though it would take the images.
|
||||||
background: drop.has-drag
|
background: drop.has-drag
|
||||||
? Theme.selected
|
? Theme.selected
|
||||||
: (root.selected ? Theme.surface-raised
|
: ((root.lifted || root.selected) ? Theme.surface-raised
|
||||||
: (touch.has-hover ? Theme.hover : transparent));
|
: (touch.has-hover ? Theme.hover : transparent));
|
||||||
border-radius: Theme.radius-sm;
|
border-radius: Theme.radius-sm;
|
||||||
|
|
||||||
// A drop target is outlined as well as filled: on a dark ground a fill
|
// A drop target is outlined as well as filled: on a dark ground a fill
|
||||||
// change alone is easy to miss mid-drag, when the user is watching the
|
// change alone is easy to miss mid-drag, when the user is watching the
|
||||||
// thumbnail under the cursor rather than the row.
|
// thumbnail under the cursor rather than the row.
|
||||||
border-width: drop.has-drag ? 1px : 0px;
|
//
|
||||||
border-color: Theme.selected-ring;
|
// A *lifted* row is outlined in the active colour instead, and the
|
||||||
|
// distinction matters: one ring says "let go here", the other says "this
|
||||||
|
// is what you are holding". Drawn the moment the hold fires, so the user
|
||||||
|
// knows the drag is armed before they move rather than after.
|
||||||
|
border-width: (drop.has-drag || root.lifted) ? 1px : 0px;
|
||||||
|
border-color: root.lifted ? Theme.active : Theme.selected-ring;
|
||||||
|
|
||||||
// Behind the content, so the row's own TouchArea still gets ordinary
|
// Behind the content, so the row's own TouchArea still gets ordinary
|
||||||
// clicks. A DropArea only takes part in a drag; it does not block presses.
|
// clicks. A DropArea only takes part in a drag; it does not block presses.
|
||||||
@@ -135,9 +147,9 @@ component TreeRow inherits Rectangle {
|
|||||||
// source's allowed set, treating anything outside it as `none`.
|
// source's allowed set, treating anything outside it as `none`.
|
||||||
//
|
//
|
||||||
// So a hard-coded `copy` accepted images and *silently refused
|
// So a hard-coded `copy` accepted images and *silently refused
|
||||||
// every collection*: nesting by drag could not work at all, and it
|
// every collection*: nesting by drag could not work, and it failed
|
||||||
// failed with nothing to see, because an action clamped to `none`
|
// with no error anywhere, because a clamped-to-none action is
|
||||||
// is indistinguishable from a target that simply said no.
|
// indistinguishable from a target that simply said no.
|
||||||
return ev.proposed-action;
|
return ev.proposed-action;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,6 +332,47 @@ component TreeRow inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GESTURE: Pick a collection up to rearrange the tree
|
||||||
|
// where: Collections sidebar
|
||||||
|
// touch: Press and hold it until it lifts, then drag it
|
||||||
|
// pointer: Drag it, or hold it until it lifts and then drag
|
||||||
|
// why: The tree is inside a Flickable, which claims any drag
|
||||||
|
// beginning inside it — so with a finger a drag on a row is a
|
||||||
|
// scroll until something says otherwise. The hold is that
|
||||||
|
// something, and it is what every mobile list already uses to
|
||||||
|
// pick a row up. The row lifts the moment it fires, so the
|
||||||
|
// gesture says it has been understood before anything moves.
|
||||||
|
//
|
||||||
|
// GESTURE: Act on a collection — rename, nest, un-nest, delete
|
||||||
|
// where: Collections sidebar
|
||||||
|
// touch: Press and hold the collection, then let go without moving
|
||||||
|
// pointer: Right-click it
|
||||||
|
// why: The hold arms a drag and opens this menu, and which one you
|
||||||
|
// get is decided by whether you moved — the same fork the grid
|
||||||
|
// uses. One menu for everything done to a row, because there is
|
||||||
|
// one hold per row: while the hold opened the offline question
|
||||||
|
// by itself, nothing else the tree can do had a touch route.
|
||||||
|
//
|
||||||
|
// GESTURE: Take a collection back out of the one it is nested in
|
||||||
|
// where: Collections sidebar
|
||||||
|
// touch: Hold it, then drag it onto "All photographs" — or let go and
|
||||||
|
// choose "Move to top level"
|
||||||
|
// pointer: Drag it onto "All photographs", or right-click it and choose
|
||||||
|
// "Move to top level"
|
||||||
|
// why: Nesting is a drag of one row onto another, and its inverse
|
||||||
|
// had no gesture at all: "All photographs" refused every drop,
|
||||||
|
// which is right for a photograph — it is already in the
|
||||||
|
// library — and wrong for a collection, which has a top level
|
||||||
|
// to be returned to. Without it a collection dragged into
|
||||||
|
// another was in there permanently.
|
||||||
|
//
|
||||||
|
// GESTURE: Rename a collection
|
||||||
|
// where: Collections sidebar
|
||||||
|
// touch: Hold the collection, then "Rename"
|
||||||
|
// pointer: Double-click its name, or right-click it and choose "Rename"
|
||||||
|
// why: Double-click is what a file manager and a Lightroom panel use
|
||||||
|
// for the same thing, so it needs no discovering — but nothing
|
||||||
|
// on screen says so, which is what the menu item is for.
|
||||||
touch := TouchArea {
|
touch := TouchArea {
|
||||||
// Disabled while the field is up, so a click landing on the row rather
|
// Disabled while the field is up, so a click landing on the row rather
|
||||||
// than inside the input does not re-select the collection out from
|
// than inside the input does not re-select the collection out from
|
||||||
@@ -400,6 +453,29 @@ export component CollectionsPanel inherits Rectangle {
|
|||||||
/// A drag is dwelling over a collection, or has left it. Drives the
|
/// A drag is dwelling over a collection, or has left it. Drives the
|
||||||
/// spring-loaded expansion, which is timed in Rust.
|
/// spring-loaded expansion, which is timed in Rust.
|
||||||
callback drag-over(int, bool);
|
callback drag-over(int, bool);
|
||||||
|
|
||||||
|
// --- picking a collection up (FR-UI-3, FR-UI-4) ------------------------
|
||||||
|
//
|
||||||
|
// A press and hold arms the drag and lifts the row; moving then rearranges
|
||||||
|
// the tree, and letting go without moving opens the row menu instead.
|
||||||
|
//
|
||||||
|
// The hold is what makes this reliable with a finger. This tree is inside
|
||||||
|
// a Flickable, and a Flickable claims any drag beginning inside it — so a
|
||||||
|
// touch drag on a row is a scroll until something says otherwise. The hold
|
||||||
|
// is that something, and it is the gesture every mobile list already uses
|
||||||
|
// to pick a row up, so it needs no discovering.
|
||||||
|
/// Which collection is picked up, by id. 0 is none.
|
||||||
|
in property <int> lifted-id: 0;
|
||||||
|
/// A row's drag crossed the threshold, or ended. What tells Rust the hold
|
||||||
|
/// became a rearrangement rather than a menu.
|
||||||
|
callback drag-active(int, bool);
|
||||||
|
/// Whether a drop on "All photographs" would do anything — true only while
|
||||||
|
/// a *nested* collection is in flight. Rust decides, because only it knows
|
||||||
|
/// what the drag is carrying and whether that thing has a parent.
|
||||||
|
in property <bool> root-drop-allowed: false;
|
||||||
|
/// A collection was dropped on "All photographs": take it out to the top
|
||||||
|
/// level. The inverse of dropping one row onto another.
|
||||||
|
callback dropped-on-root();
|
||||||
callback new-collection();
|
callback new-collection();
|
||||||
// Right-click on a row: rename, delete, new child.
|
// Right-click on a row: rename, delete, new child.
|
||||||
callback row-menu(int);
|
callback row-menu(int);
|
||||||
@@ -500,9 +576,43 @@ export component CollectionsPanel inherits Rectangle {
|
|||||||
// everything" a thing you have to find.
|
// everything" a thing you have to find.
|
||||||
Rectangle {
|
Rectangle {
|
||||||
height: Theme.row-height;
|
height: Theme.row-height;
|
||||||
background: root.selected-id == 0 ? Theme.surface-raised
|
background: all-drop.has-drag
|
||||||
: (all-touch.has-hover ? Theme.hover : transparent);
|
? Theme.selected
|
||||||
|
: (root.selected-id == 0 ? Theme.surface-raised
|
||||||
|
: (all-touch.has-hover ? Theme.hover : transparent));
|
||||||
border-radius: Theme.radius-sm;
|
border-radius: Theme.radius-sm;
|
||||||
|
border-width: all-drop.has-drag ? 1px : 0px;
|
||||||
|
border-color: Theme.selected-ring;
|
||||||
|
|
||||||
|
// TRACES: FR-CAT-7
|
||||||
|
// Dropping a *collection* here takes it out to the top level —
|
||||||
|
// the inverse of dropping one row onto another, which nests it.
|
||||||
|
//
|
||||||
|
// Photographs still cannot be dropped here, and the refusal is the
|
||||||
|
// same one this row has always made: an image is already in the
|
||||||
|
// library, so there is nothing for the drop to do. `can-drop`
|
||||||
|
// separates the two, so the cursor says which of them this is
|
||||||
|
// before the release rather than after.
|
||||||
|
all-drop := DropArea {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
can-drop(ev) => {
|
||||||
|
if (!root.root-drop-allowed) {
|
||||||
|
return DragAction.none;
|
||||||
|
}
|
||||||
|
// Echoed rather than named, as everywhere else here: only
|
||||||
|
// a collection can reach this branch, and its DragArea
|
||||||
|
// allows `move` — but saying so twice is how the two drift
|
||||||
|
// apart.
|
||||||
|
return ev.proposed-action;
|
||||||
|
}
|
||||||
|
|
||||||
|
dropped(ev) => {
|
||||||
|
root.dropped-on-root();
|
||||||
|
return ev.proposed-action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
padding-left: Theme.gap-sm;
|
padding-left: Theme.gap-sm;
|
||||||
@@ -526,9 +636,6 @@ export component CollectionsPanel inherits Rectangle {
|
|||||||
}
|
}
|
||||||
all-touch := TouchArea {
|
all-touch := TouchArea {
|
||||||
clicked => { root.select(0); }
|
clicked => { root.select(0); }
|
||||||
// Dropping onto "All photographs" would mean nothing — an
|
|
||||||
// image is already in the library — so this reports no hover
|
|
||||||
// during a drag and stays inert.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,11 +664,17 @@ export component CollectionsPanel inherits Rectangle {
|
|||||||
allow-move: true;
|
allow-move: true;
|
||||||
data: root.collection-drag-payload(row.id);
|
data: root.collection-drag-payload(row.id);
|
||||||
|
|
||||||
|
// The hold armed this drag and lifted the row; this is where
|
||||||
|
// Rust learns the movement actually began, so the release that
|
||||||
|
// ends it opens no menu.
|
||||||
|
changed dragging => { root.drag-active(row.id, self.dragging); }
|
||||||
|
|
||||||
TreeRow {
|
TreeRow {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
entry: row;
|
entry: row;
|
||||||
selected: row.id == root.selected-id;
|
selected: row.id == root.selected-id;
|
||||||
|
lifted: row.id == root.lifted-id;
|
||||||
// A saved filter's membership is computed, so a drop cannot
|
// A saved filter's membership is computed, so a drop cannot
|
||||||
// land there. Refused in `can-drop`, so the cursor says no
|
// land there. Refused in `can-drop`, so the cursor says no
|
||||||
// before the release rather than after.
|
// before the release rather than after.
|
||||||
@@ -653,15 +766,15 @@ export component CollectionsPanel inherits Rectangle {
|
|||||||
trash-drop := DropArea {
|
trash-drop := DropArea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
// Echoed from the source, like the row above: the grid's
|
// Echoed from the source, like every other target here: the
|
||||||
// cells allow `copy` alone, so the `move` this used to insist
|
// grid's cells allow `copy` alone, so the `move` this used to
|
||||||
// on was clamped to `none` and the trash refused every
|
// return was clamped to `none` and the trash refused every
|
||||||
// photograph dragged onto it.
|
// photograph dragged to it.
|
||||||
//
|
//
|
||||||
// What this row does is destructive whatever the negotiated
|
// What this row does is destructive whatever the negotiated
|
||||||
// action is called — `dropped-on-trash` moves the file — so
|
// action is called — `dropped-on-trash` moves the file — so
|
||||||
// the name was never what made it so, and naming it cost the
|
// the name of the action was never what made it so, and
|
||||||
// feature.
|
// insisting on `move` only cost the feature.
|
||||||
can-drop(ev) => { return ev.proposed-action; }
|
can-drop(ev) => { return ev.proposed-action; }
|
||||||
dropped(ev) => {
|
dropped(ev) => {
|
||||||
root.dropped-on-trash();
|
root.dropped-on-trash();
|
||||||
@@ -878,3 +991,320 @@ export component OfflinePrompt inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The actions on one collection row: rename, nest a new one inside, take it
|
||||||
|
// back out to the top level, delete it.
|
||||||
|
//
|
||||||
|
// This replaced a right-click that *deleted an empty collection outright* and
|
||||||
|
// declined with an error message otherwise. Two things were wrong with that,
|
||||||
|
// and they pull in opposite directions: the safe half was undiscoverable — no
|
||||||
|
// affordance says a row can be deleted, and the one gesture that could was
|
||||||
|
// destructive with no confirmation — while the unsafe half was unreachable, so
|
||||||
|
// a collection that held anything could not be deleted at all without first
|
||||||
|
// emptying it by hand, child by child.
|
||||||
|
//
|
||||||
|
// Drawn over the whole window rather than as a popup anchored to the row, for
|
||||||
|
// the reason `OfflinePrompt` above gives: the sidebar is 232px wide and closes
|
||||||
|
// entirely in the compact layout, so a menu living inside it would be
|
||||||
|
// unreachable on the device the long-press exists for.
|
||||||
|
export component CollectionMenu inherits Rectangle {
|
||||||
|
/// The collection being acted on. Empty closes the menu — one source for
|
||||||
|
/// "is this open", rather than a bool that can disagree with the name
|
||||||
|
/// beside it.
|
||||||
|
in property <string> title;
|
||||||
|
/// What is in there — "12 photographs · 2 collections inside". Composed in
|
||||||
|
/// Rust, which has the counts; this file does no arithmetic on them.
|
||||||
|
in property <string> detail;
|
||||||
|
/// Whether this collection has a parent, and so has somewhere to be moved
|
||||||
|
/// out to. Hidden rather than disabled at the top level: "move to top
|
||||||
|
/// level" on something already there is not a refusal worth explaining.
|
||||||
|
in property <bool> nested: false;
|
||||||
|
/// A saved filter, whose membership is its selector. It can still be
|
||||||
|
/// renamed, moved and deleted; what it cannot do is take images, so
|
||||||
|
/// nesting a new collection inside one would build a container whose
|
||||||
|
/// contents can never reach it.
|
||||||
|
in property <bool> smart: false;
|
||||||
|
/// Whether the delete has been asked once and is waiting to be confirmed.
|
||||||
|
/// Owned by Rust, which decides from the counts whether a confirmation is
|
||||||
|
/// needed at all — an empty collection is deleted on the first press.
|
||||||
|
in property <bool> confirming: false;
|
||||||
|
/// What deleting would actually do, spelled out. The photographs survive,
|
||||||
|
/// and a user who reads "Delete" as "delete my pictures" will not use this
|
||||||
|
/// feature again.
|
||||||
|
in property <string> confirm-detail;
|
||||||
|
/// The destructive label, with what it takes with it.
|
||||||
|
in property <string> delete-label: "Delete";
|
||||||
|
/// TRACES: FR-NC-6a
|
||||||
|
/// Whether this collection is being kept on the device, for the label on
|
||||||
|
/// the offline item.
|
||||||
|
in property <bool> pinned: false;
|
||||||
|
|
||||||
|
callback rename();
|
||||||
|
callback new-child();
|
||||||
|
/// Reparent to the top level — the un-nesting that had no gesture at all.
|
||||||
|
/// Nesting has one (drag a row onto another); its inverse had none, so a
|
||||||
|
/// collection put inside another was there permanently.
|
||||||
|
callback promote();
|
||||||
|
/// TRACES: FR-NC-6a
|
||||||
|
/// Open the offline question about this collection — the same one the tray
|
||||||
|
/// on the row opens.
|
||||||
|
callback offline();
|
||||||
|
callback delete();
|
||||||
|
callback dismiss();
|
||||||
|
|
||||||
|
visible: root.title != "";
|
||||||
|
background: #000000CC;
|
||||||
|
|
||||||
|
// Swallows everything that misses the card, and dismisses. Declared before
|
||||||
|
// the card so the card's own controls sit above it.
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.dismiss(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: min(420px, parent.width - 2 * Theme.gap-lg);
|
||||||
|
height: card.preferred-height;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
background: Theme.surface;
|
||||||
|
border-radius: Theme.radius;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.rule;
|
||||||
|
|
||||||
|
// A press on the card must not reach the scrim behind it and close the
|
||||||
|
// thing being read.
|
||||||
|
TouchArea { }
|
||||||
|
|
||||||
|
card := VerticalLayout {
|
||||||
|
padding: Theme.gap-lg;
|
||||||
|
spacing: Theme.gap;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "COLLECTION";
|
||||||
|
color: Theme.ink-faint;
|
||||||
|
font-size: Theme.text-sm;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.title;
|
||||||
|
color: Theme.ink;
|
||||||
|
font-size: Theme.text-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.detail;
|
||||||
|
color: Theme.ink-dim;
|
||||||
|
font-size: Theme.text;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 1px; background: Theme.rule; }
|
||||||
|
|
||||||
|
// The three non-destructive actions, hidden while a delete is
|
||||||
|
// waiting to be confirmed: a confirmation that leaves the other
|
||||||
|
// choices live is one mis-aimed press away from doing something
|
||||||
|
// else entirely, and the user is at that moment reading rather
|
||||||
|
// than aiming.
|
||||||
|
if !root.confirming: Button {
|
||||||
|
text: "Rename";
|
||||||
|
clicked => { root.rename(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if !root.confirming && !root.smart: Button {
|
||||||
|
text: "New collection inside";
|
||||||
|
clicked => { root.new-child(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if !root.confirming && root.nested: Button {
|
||||||
|
text: "Move to top level";
|
||||||
|
clicked => { root.promote(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-NC-6a | FR-UI-3
|
||||||
|
// The offline question, which the long press used to open on its
|
||||||
|
// own. It is here now because the press had to be given to this
|
||||||
|
// menu — there is one hold per row and the tree's other actions
|
||||||
|
// had no touch route at all without it.
|
||||||
|
//
|
||||||
|
// Nothing is lost by the move and something is gained: the tray on
|
||||||
|
// the row keeps its direct tap for the fast way in, and this is a
|
||||||
|
// full-width control rather than a 30px icon in a row shorter than
|
||||||
|
// the touch minimum, so the question now has a target that meets
|
||||||
|
// FR-UI-3 as well as a gesture that finds it.
|
||||||
|
if !root.confirming: Button {
|
||||||
|
text: root.pinned ? "Kept offline" : "Keep offline";
|
||||||
|
active: root.pinned;
|
||||||
|
clicked => { root.offline(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if !root.confirming: Rectangle { height: 1px; background: Theme.rule; }
|
||||||
|
|
||||||
|
// What the delete takes with it, in the words of the thing it
|
||||||
|
// takes. Only while confirming: shown always, it would be a
|
||||||
|
// warning about an action nobody has asked for yet, which is how a
|
||||||
|
// dialogue teaches people to stop reading it.
|
||||||
|
if root.confirming: Text {
|
||||||
|
text: root.confirm-detail;
|
||||||
|
color: Theme.warn-ink;
|
||||||
|
font-size: Theme.text;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: root.delete-label;
|
||||||
|
clicked => { root.delete(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Cancel";
|
||||||
|
clicked => { root.dismiss(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One collection the current selection is filed in.
|
||||||
|
export struct MembershipRow {
|
||||||
|
id: int,
|
||||||
|
name: string,
|
||||||
|
// How many of the selected photographs are in here, and how many were
|
||||||
|
// selected. Both, because "3" alone is ambiguous and "3 of 40" is not.
|
||||||
|
holding: int,
|
||||||
|
// Already formatted — "3 of 40", or just the count when one photograph is
|
||||||
|
// selected and the fraction would be noise.
|
||||||
|
detail: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where the selection is filed, and the way out of any of it.
|
||||||
|
//
|
||||||
|
// The library's "Remove from collection" button takes the selection out of the
|
||||||
|
// collection the *grid is scoped to*, which is the only one it can name. That
|
||||||
|
// leaves no way to take a photograph out of a collection without first
|
||||||
|
// navigating to that collection — and no way at all to find out which
|
||||||
|
// collections a photograph is in, since the grid badges a cell with a count
|
||||||
|
// and nothing else.
|
||||||
|
export component MembershipSheet inherits Rectangle {
|
||||||
|
/// Whether the sheet is up. A bool rather than a non-empty string as the
|
||||||
|
/// menu above uses, because this sheet is legitimately empty: a selection
|
||||||
|
/// filed nowhere is a real state, and it is worth saying so.
|
||||||
|
in property <bool> open: false;
|
||||||
|
/// How many photographs the answers are about.
|
||||||
|
in property <int> selected-count: 0;
|
||||||
|
/// The collections holding at least one of them, by name.
|
||||||
|
in property <[MembershipRow]> rows;
|
||||||
|
|
||||||
|
/// Take the selection out of this collection, by id.
|
||||||
|
callback remove(int);
|
||||||
|
callback dismiss();
|
||||||
|
|
||||||
|
visible: root.open;
|
||||||
|
background: #000000CC;
|
||||||
|
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.dismiss(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: min(420px, parent.width - 2 * Theme.gap-lg);
|
||||||
|
height: min(card.preferred-height, parent.height - 2 * Theme.gap-lg);
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
background: Theme.surface;
|
||||||
|
border-radius: Theme.radius;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.rule;
|
||||||
|
|
||||||
|
TouchArea { }
|
||||||
|
|
||||||
|
card := VerticalLayout {
|
||||||
|
padding: Theme.gap-lg;
|
||||||
|
spacing: Theme.gap;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "FILED IN";
|
||||||
|
color: Theme.ink-faint;
|
||||||
|
font-size: Theme.text-sm;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.selected-count == 1 ? "1 photograph"
|
||||||
|
: root.selected-count + " photographs";
|
||||||
|
color: Theme.ink;
|
||||||
|
font-size: Theme.text-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The empty case says what it means. A blank list under a heading
|
||||||
|
// reads as a sheet that failed to load.
|
||||||
|
if root.rows.length == 0: Text {
|
||||||
|
text: "Not in any collection. Drag onto a collection in the sidebar to file it.";
|
||||||
|
color: Theme.ink-dim;
|
||||||
|
font-size: Theme.text;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.rows.length > 0: Rectangle { height: 1px; background: Theme.rule; }
|
||||||
|
|
||||||
|
if root.rows.length > 0: Flickable {
|
||||||
|
// A floor so one row is not squeezed out of existence, and a
|
||||||
|
// ceiling so a photograph filed in thirty collections does not
|
||||||
|
// push the Close button off the bottom of the window. The card
|
||||||
|
// is capped in height above; this is what keeps the control
|
||||||
|
// below the list inside that cap.
|
||||||
|
min-height: min(root.rows.length * (Theme.touch-target + 2px), 100px);
|
||||||
|
max-height: 260px;
|
||||||
|
viewport-height: root.rows.length * (Theme.touch-target + 2px);
|
||||||
|
|
||||||
|
for entry[i] in root.rows: HorizontalLayout {
|
||||||
|
y: i * (Theme.touch-target + 2px);
|
||||||
|
width: parent.width;
|
||||||
|
height: Theme.touch-target;
|
||||||
|
spacing: Theme.gap-sm;
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
alignment: center;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: entry.name;
|
||||||
|
color: Theme.ink;
|
||||||
|
font-size: Theme.text;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
// Absent, not blank, with one photograph selected:
|
||||||
|
// "1 of 1" is arithmetic nobody asked for, and an
|
||||||
|
// empty line still takes its height.
|
||||||
|
if entry.detail != "": Text {
|
||||||
|
text: entry.detail;
|
||||||
|
color: Theme.ink-faint;
|
||||||
|
font-size: Theme.text-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Remove", not "Delete": this drops a membership row and
|
||||||
|
// the photographs stay in the library and in every other
|
||||||
|
// collection. The two words are one keystroke apart in the
|
||||||
|
// source and a catastrophe apart to the user.
|
||||||
|
Button {
|
||||||
|
text: "Remove";
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
clicked => { root.remove(entry.id); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 1px; background: Theme.rule; }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Close";
|
||||||
|
clicked => { root.dismiss(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1617,6 +1617,12 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
/// offered when the grid is scoped to one — "remove from library" is not a
|
/// offered when the grid is scoped to one — "remove from library" is not a
|
||||||
/// thing this button does.
|
/// thing this button does.
|
||||||
callback remove-from-collection();
|
callback remove-from-collection();
|
||||||
|
/// Show every collection the selection is filed in, with a way out of each.
|
||||||
|
/// The button above can only ever name the scoped collection; this is how a
|
||||||
|
/// photograph leaves one the grid is not currently showing — and the only
|
||||||
|
/// way to find out which collections it is in at all, since the cell badges
|
||||||
|
/// a count and nothing else.
|
||||||
|
callback open-membership();
|
||||||
|
|
||||||
// --- filing the selection (FR-CAT-7, FR-UI-4) ---------------------------
|
// --- filing the selection (FR-CAT-7, FR-UI-4) ---------------------------
|
||||||
//
|
//
|
||||||
@@ -4248,6 +4254,27 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
clicked => { root.remove-from-collection(); }
|
clicked => { root.remove-from-collection(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-CAT-7 | FR-UI-4
|
||||||
|
// Where this selection is filed, and the way out of any of it.
|
||||||
|
//
|
||||||
|
// Unscoped as well as scoped, unlike the button above: this is the
|
||||||
|
// one control that can name a collection the grid is not showing,
|
||||||
|
// and hiding it outside a scope would leave "which collections is
|
||||||
|
// this photograph in" answerable only by visiting each one.
|
||||||
|
// GESTURE: Take photographs out of a collection
|
||||||
|
// where: Library grid
|
||||||
|
// touch: Select them, then "Collections…" in the selection bar
|
||||||
|
// pointer: Select them, then "Collections…" in the selection bar
|
||||||
|
// why: The badge on a cell says a photograph is filed in
|
||||||
|
// three collections and never which. This is the sheet
|
||||||
|
// that names them, and the only way out of one the grid
|
||||||
|
// is not currently scoped to.
|
||||||
|
if !root.ranging && root.selected-count > 0: Button {
|
||||||
|
text: "Collections…";
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
clicked => { root.open-membership(); }
|
||||||
|
}
|
||||||
|
|
||||||
// TRACES: FR-EXP-7 | NFR-ARCH-3
|
// TRACES: FR-EXP-7 | NFR-ARCH-3
|
||||||
// Export the selection, and stop the batch that is running.
|
// Export the selection, and stop the batch that is running.
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user