Show and set colour labels in the grid and develop, and filter by them

Colour labels could be read from a Lightroom sidecar and queried by the
selector, but nothing drew one or set one, so the only labels a library
held were ones another program had written.

Every mark carries its label's initial on its colour — R, Y, G, B, P —
so a label is read without telling red from green, which is what
NFR-A11Y-3 asks of colour labels by name. A grid cell shows the mark
before its filename. In the grid, 6, 7, 8 and 9 set red, yellow, green and
blue as Lightroom's keys do, on the photograph under the pointer or on
the selection by the rule the star keys follow; the same key again takes
the label off, and over a mixed selection it sets it on all. The
selection bar gains Label, which opens the six choices — each a mark and
a name — and purple, which has no key, is there. In develop the top bar
says "Label: Green" beside the mark, opens the same choices, and 6-9
label the open photograph.

Each gesture is one catalog transaction, then the grid, the counts and
both sidecars are written as a rating's are. The filter bar gains a chip
per label, its mark and its name with a count, one at a time; the filter
is one SQL term, travels in the place record, and "All" clears it.
This commit is contained in:
2026-09-24 21:52:23 -04:00
parent d748527a4c
commit 46f5b95828
18 changed files with 860 additions and 86 deletions
+4 -1
View File
@@ -814,7 +814,10 @@ mod tests {
assert!(all
.iter()
.all(|id| found[id].label == Some(ColourLabel::Blue)));
assert_eq!(label_histogram(cat.connection()).unwrap(), [0, 0, 0, 0, 4, 0]);
assert_eq!(
label_histogram(cat.connection()).unwrap(),
[0, 0, 0, 0, 4, 0]
);
}
#[test]
+4
View File
@@ -135,6 +135,10 @@ pub struct StoredFilter {
/// Travels: it is a narrowing like `local_only`, and a record without it
/// — from a build before it existed — reads as off.
pub eyes_open: bool,
/// TRACES: FR-CAT-6
/// The colour label the grid was narrowed to. A record without it reads
/// as none, as every term added after the first does.
pub label: Option<crate::ColourLabel>,
}
/// Where the photographer was, at the moment they were there.
File diff suppressed because one or more lines are too long
+107 -18
View File
@@ -5,7 +5,11 @@
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.
<<<<<<< HEAD
48 gestures, in 4 places.
=======
50 gestures, in 4 places.
>>>>>>> b2428ce (Show and set colour labels in the grid and develop, and filter by them)
## Develop
@@ -25,6 +29,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. Past 1:1 the pixels are shown as they are, square and unsmoothed; below it, filtered.
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<sub>`ui/dr-ui/ui/app.slint:1715`</sub>
@@ -34,6 +39,9 @@ Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as m
=======
<sub>`ui/dr-ui/ui/app.slint:1755`</sub>
>>>>>>> 3f5f735 (Fade the draft into the sharp frame when a drag settles)
=======
<sub>`ui/dr-ui/ui/app.slint:1722`</sub>
>>>>>>> b2428ce (Show and set colour labels in the grid and develop, and filter by them)
### Move a magnified photograph about
@@ -42,6 +50,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.
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<sub>`ui/dr-ui/ui/app.slint:1808`</sub>
@@ -51,6 +60,9 @@ Only once there is something outside the viewport to reach, which is why the cur
=======
<sub>`ui/dr-ui/ui/app.slint:1846`</sub>
>>>>>>> 3f5f735 (Fade the draft into the sharp frame when a drag settles)
=======
<sub>`ui/dr-ui/ui/app.slint:1813`</sub>
>>>>>>> b2428ce (Show and set colour labels in the grid and develop, and filter by them)
### Paint a mask by hand
@@ -59,6 +71,7 @@ Only once there is something outside the viewport to reach, which is why the cur
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<sub>`ui/dr-ui/ui/app.slint:1895`</sub>
@@ -68,6 +81,9 @@ A model's mask stops inside a shoulder and leaks into the hair, and no single ed
=======
<sub>`ui/dr-ui/ui/app.slint:1933`</sub>
>>>>>>> 3f5f735 (Fade the draft into the sharp frame when a drag settles)
=======
<sub>`ui/dr-ui/ui/app.slint:1900`</sub>
>>>>>>> b2428ce (Show and set colour labels in the grid and develop, and filter by them)
### Take back the last change
@@ -80,6 +96,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<sub>`ui/dr-ui/ui/app.slint:2123`</sub>
=======
<sub>`ui/dr-ui/ui/app.slint:2116`</sub>
@@ -90,6 +107,9 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
=======
<sub>`ui/dr-ui/ui/app.slint:2161`</sub>
>>>>>>> 3f5f735 (Fade the draft into the sharp frame when a drag settles)
=======
<sub>`ui/dr-ui/ui/app.slint:2128`</sub>
>>>>>>> b2428ce (Show and set colour labels in the grid and develop, and filter by them)
### Do it again after taking it back
@@ -100,6 +120,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<sub>`ui/dr-ui/ui/app.slint:2136`</sub>
=======
<sub>`ui/dr-ui/ui/app.slint:2129`</sub>
@@ -110,6 +131,9 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
=======
<sub>`ui/dr-ui/ui/app.slint:2174`</sub>
>>>>>>> 3f5f735 (Fade the draft into the sharp frame when a drag settles)
=======
<sub>`ui/dr-ui/ui/app.slint:2141`</sub>
>>>>>>> b2428ce (Show and set colour labels in the grid and develop, and filter by them)
### Copy the settings from this photograph
@@ -122,6 +146,7 @@ The button is the copy that has to work: a tablet has no modifier key to hold an
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<sub>`ui/dr-ui/ui/app.slint:2169`</sub>
=======
<sub>`ui/dr-ui/ui/app.slint:2162`</sub>
@@ -132,6 +157,9 @@ The button is the copy that has to work: a tablet has no modifier key to hold an
=======
<sub>`ui/dr-ui/ui/app.slint:2207`</sub>
>>>>>>> 3f5f735 (Fade the draft into the sharp frame when a drag settles)
=======
<sub>`ui/dr-ui/ui/app.slint:2174`</sub>
>>>>>>> b2428ce (Show and set colour labels in the grid and develop, and filter by them)
### Paste the settings onto this photograph
@@ -144,6 +172,7 @@ The button names what would be pasted — "3 adjustments", and whether the crop
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<sub>`ui/dr-ui/ui/app.slint:2181`</sub>
=======
<sub>`ui/dr-ui/ui/app.slint:2174`</sub>
@@ -154,6 +183,9 @@ The button names what would be pasted — "3 adjustments", and whether the crop
=======
<sub>`ui/dr-ui/ui/app.slint:2219`</sub>
>>>>>>> 3f5f735 (Fade the draft into the sharp frame when a drag settles)
=======
<sub>`ui/dr-ui/ui/app.slint:2186`</sub>
>>>>>>> b2428ce (Show and set colour labels in the grid and develop, and filter by them)
### Choose which kinds of edit a copy carries
@@ -166,6 +198,7 @@ Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving e
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<sub>`ui/dr-ui/ui/app.slint:2198`</sub>
=======
<sub>`ui/dr-ui/ui/app.slint:2191`</sub>
@@ -176,6 +209,9 @@ Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving e
=======
<sub>`ui/dr-ui/ui/app.slint:2236`</sub>
>>>>>>> 3f5f735 (Fade the draft into the sharp frame when a drag settles)
=======
<sub>`ui/dr-ui/ui/app.slint:2203`</sub>
>>>>>>> b2428ce (Show and set colour labels in the grid and develop, and filter by them)
### Export this photograph as the last one was
@@ -188,6 +224,7 @@ Every export runs on the defaults in Settings, so "as the last one was" is what
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<sub>`ui/dr-ui/ui/app.slint:2227`</sub>
=======
<sub>`ui/dr-ui/ui/app.slint:2220`</sub>
@@ -198,6 +235,9 @@ Every export runs on the defaults in Settings, so "as the last one was" is what
=======
<sub>`ui/dr-ui/ui/app.slint:2265`</sub>
>>>>>>> 3f5f735 (Fade the draft into the sharp frame when a drag settles)
=======
<sub>`ui/dr-ui/ui/app.slint:2232`</sub>
>>>>>>> b2428ce (Show and set colour labels in the grid and develop, and filter by them)
### Choose how to export, then export
@@ -210,6 +250,7 @@ The export sheet is the export defaults alone with an Export button. What is cho
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<sub>`ui/dr-ui/ui/app.slint:2239`</sub>
=======
<sub>`ui/dr-ui/ui/app.slint:2232`</sub>
@@ -220,6 +261,9 @@ The export sheet is the export defaults alone with an Export button. What is cho
=======
<sub>`ui/dr-ui/ui/app.slint:2277`</sub>
>>>>>>> 3f5f735 (Fade the draft into the sharp frame when a drag settles)
=======
<sub>`ui/dr-ui/ui/app.slint:2244`</sub>
>>>>>>> b2428ce (Show and set colour labels in the grid and develop, and filter by them)
### Change which group of adjustments is on screen
@@ -232,6 +276,7 @@ The groups are whatever the operation set declares itself to be about, so there
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<sub>`ui/dr-ui/ui/app.slint:2264`</sub>
=======
<sub>`ui/dr-ui/ui/app.slint:2257`</sub>
@@ -242,6 +287,9 @@ The groups are whatever the operation set declares itself to be about, so there
=======
<sub>`ui/dr-ui/ui/app.slint:2302`</sub>
>>>>>>> 3f5f735 (Fade the draft into the sharp frame when a drag settles)
=======
<sub>`ui/dr-ui/ui/app.slint:2269`</sub>
>>>>>>> b2428ce (Show and set colour labels in the grid and develop, and filter by them)
### Look at the photograph at 1:1
@@ -254,6 +302,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<sub>`ui/dr-ui/ui/app.slint:2299`</sub>
=======
<sub>`ui/dr-ui/ui/app.slint:2292`</sub>
@@ -264,6 +313,19 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
=======
<sub>`ui/dr-ui/ui/app.slint:2337`</sub>
>>>>>>> 3f5f735 (Fade the draft into the sharp frame when a drag settles)
=======
<sub>`ui/dr-ui/ui/app.slint:2304`</sub>
### Give this photograph a colour label
- **Touch** — Tap Label in the top bar, then a colour
- **Pointer** — Click Label in the top bar, then a colour
- **Keyboard** — 6 red, 7 yellow, 8 green, 9 blue; the same key again takes it off
The grid's keys, on the photograph that is open, so labelling while stepping through a folder is one hand's work. The bar names the label in words beside its mark.
<sub>`ui/dr-ui/ui/app.slint:2356`</sub>
>>>>>>> b2428ce (Show and set colour labels in the grid and develop, and filter by them)
### Move to the next or previous photograph
@@ -276,6 +338,7 @@ The edit on screen is saved on the way out, so stepping through a folder is as m
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<sub>`ui/dr-ui/ui/app.slint:2354`</sub>
=======
<sub>`ui/dr-ui/ui/app.slint:2344`</sub>
@@ -286,6 +349,9 @@ The edit on screen is saved on the way out, so stepping through a folder is as m
=======
<sub>`ui/dr-ui/ui/app.slint:2389`</sub>
>>>>>>> 3f5f735 (Fade the draft into the sharp frame when a drag settles)
=======
<sub>`ui/dr-ui/ui/app.slint:2381`</sub>
>>>>>>> b2428ce (Show and set colour labels in the grid and develop, and filter by them)
### See the photograph before you edited it
@@ -298,6 +364,7 @@ Held rather than toggled, and no split screen: a split halves the working image
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<sub>`ui/dr-ui/ui/app.slint:2487`</sub>
=======
<sub>`ui/dr-ui/ui/app.slint:2477`</sub>
@@ -308,6 +375,9 @@ Held rather than toggled, and no split screen: a split halves the working image
=======
<sub>`ui/dr-ui/ui/app.slint:2522`</sub>
>>>>>>> 3f5f735 (Fade the draft into the sharp frame when a drag settles)
=======
<sub>`ui/dr-ui/ui/app.slint:2514`</sub>
>>>>>>> b2428ce (Show and set colour labels in the grid and develop, and filter by them)
### Put one control back to its default
@@ -459,7 +529,7 @@ The right match confidence is a property of your library, not of the model. "Wha
Touch has no ctrl, so without a mode there is no way to select a second photograph — the first tap would open it. The hold is the fast way in and the button is the one that can be found.
<sub>`ui/dr-ui/ui/library.slint:1591`</sub>
<sub>`ui/dr-ui/ui/library.slint:1625`</sub>
### Add or remove one photograph
@@ -468,7 +538,7 @@ Touch has no ctrl, so without a mode there is no way to select a second photogra
While selecting, a tap never opens. That is the whole point of the mode: one meaning per gesture at a time. Press Done to get tap-to-open back.
<sub>`ui/dr-ui/ui/library.slint:1600`</sub>
<sub>`ui/dr-ui/ui/library.slint:1634`</sub>
### Leave selecting
@@ -476,7 +546,7 @@ While selecting, a tap never opens. That is the whole point of the mode: one mea
- **Pointer** — Press Done in the header
- **Keyboard** — Escape
<sub>`ui/dr-ui/ui/library.slint:1608`</sub>
<sub>`ui/dr-ui/ui/library.slint:1642`</sub>
### Pick a photograph up to drag it
@@ -485,7 +555,7 @@ While selecting, a tap never opens. That is the whole point of the mode: one mea
A finger on a photograph might be starting a scroll, and for the first half-second the grid assumes it is. Holding says otherwise, and the ring is the grid saying it heard — from there the drag cannot be lost to a scroll. A mouse never waits: the cursor is precise enough that a sideways drag is unambiguous from the first pixel.
<sub>`ui/dr-ui/ui/library.slint:1638`</sub>
<sub>`ui/dr-ui/ui/library.slint:1672`</sub>
### Select a range
@@ -494,7 +564,7 @@ A finger on a photograph might be starting a scroll, and for the first half-seco
This replaced a double tap, which had no visible state and could take forty photographs by accident. The run is resolved by the catalog rather than by what is on screen, so the grid can scroll between the two taps — the ranges that hurt on a tablet are longer than a screenful, which is exactly where a finger sweep runs out.
<sub>`ui/dr-ui/ui/library.slint:1703`</sub>
<sub>`ui/dr-ui/ui/library.slint:1737`</sub>
### Take the blinks out of a burst
@@ -503,7 +573,7 @@ This replaced a double tap, which had no visible state and could take forty phot
Face indexing reads each face's eyes. The chip drops frames where the chosen people are caught blinking, and leaves sunglasses and eyes it could not read alone.
<sub>`ui/dr-ui/ui/library.slint:2411`</sub>
<sub>`ui/dr-ui/ui/library.slint:2450`</sub>
### Find photographs with two people in them
@@ -512,7 +582,16 @@ Face indexing reads each face's eyes. The chip drops frames where the chosen peo
"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:2440`</sub>
<sub>`ui/dr-ui/ui/library.slint:2479`</sub>
### Show only photographs with one colour label
- **Touch** — Tap its chip in the filter bar
- **Pointer** — Click its chip in the filter bar
Each chip is the label's mark and its name, so the one you want is found by reading it; tap the lit chip again to show every label.
<sub>`ui/dr-ui/ui/library.slint:2602`</sub>
### Export the selection as the last export was
@@ -522,7 +601,7 @@ Face indexing reads each face's eyes. The chip drops frames where the chosen peo
Lightroom's and darktable's chords. Every export runs on the saved defaults, so the plain chord opens them beside an Export button and the shifted one skips straight to exporting.
<sub>`ui/dr-ui/ui/library.slint:2993`</sub>
<sub>`ui/dr-ui/ui/library.slint:3065`</sub>
### Paste copied settings onto the selection
@@ -530,7 +609,7 @@ Lightroom's and darktable's chords. Every export runs on the saved defaults, so
- **Pointer** — Select them, then "Paste to N"
- **Keyboard** — Ctrl+V
<sub>`ui/dr-ui/ui/library.slint:3017`</sub>
<sub>`ui/dr-ui/ui/library.slint:3089`</sub>
### Show only photographs with some number of stars
@@ -540,7 +619,17 @@ Lightroom's and darktable's chords. Every export runs on the saved defaults, so
The chips say "this many or more". A range with a ceiling — the twos and threes still to be decided — is the keyboard's alone, and the bar says so in words while it holds.
<sub>`ui/dr-ui/ui/library.slint:3039`</sub>
<sub>`ui/dr-ui/ui/library.slint:3111`</sub>
### Give photographs a colour label
- **Touch** — Select them, then Label in the selection bar and tap a colour
- **Pointer** — Select them, then Label in the selection bar and click a colour
- **Keyboard** — 6 red, 7 yellow, 8 green, 9 blue, with the pointer over it or on the selection; the same key again takes the label off
Lightroom's keys, so hands that learned them there need not learn them again. Purple has no key there either, and is on the bar. Every mark carries its label's initial, so the label is read without telling the colours apart.
<sub>`ui/dr-ui/ui/library.slint:3160`</sub>
### Resize the thumbnails
@@ -549,7 +638,7 @@ The chips say "this many or more". A range with a ceiling — the twos and three
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:3245`</sub>
<sub>`ui/dr-ui/ui/library.slint:3340`</sub>
### File photographs in a collection
@@ -558,7 +647,7 @@ There is no wheel on a tablet, so without the pinch the cell size could only be
The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture.
<sub>`ui/dr-ui/ui/library.slint:3442`</sub>
<sub>`ui/dr-ui/ui/library.slint:3537`</sub>
### Open a photograph
@@ -567,7 +656,7 @@ The selection is what the drag carries, which is why selecting several is worth
A tap opens; a tap that *moved* does not. Travel is what separates a deliberate tap from a hand brushing past, and it is the only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush.
<sub>`ui/dr-ui/ui/library.slint:3723`</sub>
<sub>`ui/dr-ui/ui/library.slint:3833`</sub>
### Rate a photograph without opening it
@@ -577,7 +666,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.
<sub>`ui/dr-ui/ui/library.slint:3843`</sub>
<sub>`ui/dr-ui/ui/library.slint:3953`</sub>
### Choose the frame a folded burst shows
@@ -586,7 +675,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.
<sub>`ui/dr-ui/ui/library.slint:3975`</sub>
<sub>`ui/dr-ui/ui/library.slint:4085`</sub>
### Drop the selection but keep selecting
@@ -595,7 +684,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.
<sub>`ui/dr-ui/ui/library.slint:4651`</sub>
<sub>`ui/dr-ui/ui/library.slint:4761`</sub>
### Select everything the grid is showing
@@ -605,7 +694,7 @@ Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the ne
A scoped grid of two hundred frames is two hundred taps otherwise, and "all of them, except those three" is a far more common shape than the taps it took to say it.
<sub>`ui/dr-ui/ui/library.slint:4668`</sub>
<sub>`ui/dr-ui/ui/library.slint:4778`</sub>
### Take photographs out of a collection
@@ -614,4 +703,4 @@ A scoped grid of two hundred frames is two hundred taps otherwise, and "all of t
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:4792`</sub>
<sub>`ui/dr-ui/ui/library.slint:4919`</sub>
+21
View File
@@ -113,6 +113,13 @@ pub const GESTURES: &[Gesture] = &[
pointer: "Double-click it, or press the zoom readout floating over the canvas",
keys: "Z",
},
Gesture {
title: "Give this photograph a colour label",
section: "Develop",
touch: "Tap Label in the top bar, then a colour",
pointer: "Click Label in the top bar, then a colour",
keys: "6 red, 7 yellow, 8 green, 9 blue; the same key again takes it off",
},
Gesture {
title: "Move to the next or previous photograph",
section: "Develop",
@@ -281,6 +288,13 @@ pub const GESTURES: &[Gesture] = &[
pointer: "Open the People chip on the filter bar, click each name, then switch the chip beside them to \"all of them\"",
keys: "",
},
Gesture {
title: "Show only photographs with one colour label",
section: "Library grid",
touch: "Tap its chip in the filter bar",
pointer: "Click its chip in the filter bar",
keys: "",
},
Gesture {
title: "Export the selection as the last export was",
section: "Library grid",
@@ -302,6 +316,13 @@ pub const GESTURES: &[Gesture] = &[
pointer: "Click a star chip in the filter bar",
keys: "Hold F and tap a digit for exactly that many stars, or two digits for everything between them; tap F alone to show every rating again",
},
Gesture {
title: "Give photographs a colour label",
section: "Library grid",
touch: "Select them, then Label in the selection bar and tap a colour",
pointer: "Select them, then Label in the selection bar and click a colour",
keys: "6 red, 7 yellow, 8 green, 9 blue, with the pointer over it or on the selection; the same key again takes the label off",
},
Gesture {
title: "Resize the thumbnails",
section: "Library grid",
+43
View File
@@ -153,6 +153,10 @@ pub struct RatingFilter {
/// a star count does, and nothing here rejects or rates a frame — the
/// line §3.9.1 draws.
pub eyes_open: bool,
/// TRACES: FR-CAT-6
/// Only photographs carrying this colour label. One at a time, as the
/// flag is: the chips are a choice of one pile, not a set of them.
pub label: Option<dr_types::ColourLabel>,
}
/// How several people combine when the grid is narrowed by identity.
@@ -188,6 +192,7 @@ impl RatingFilter {
&& self.captured_to.is_none()
&& self.people.is_empty()
&& !self.eyes_open
&& self.label.is_none()
}
/// Whether a date range is narrowing the grid.
@@ -349,6 +354,19 @@ impl RatingFilter {
));
}
// TRACES: FR-CAT-6
// A code this module owns, formatted in like the flag above. No
// coalesce: an unlabelled image's NULL never equals a code, which is
// the answer.
if let Some(label) = self.label {
terms.push(format!(
"(SELECT dv.label FROM versions dv
WHERE dv.image_id = i.id AND dv.is_default = 1
LIMIT 1) = {}",
dr_catalog::rating::label_code(label)
));
}
if self.local_only {
// `tier_actual`, not `tier_desired`: the question is what is
// *here*, not what a pin has promised will be. An image queued for
@@ -419,6 +437,31 @@ mod tests {
assert_eq!(local_original_count(&catalog).unwrap(), 3);
}
#[test]
fn a_label_chip_shows_that_label_and_nothing_else() {
// TRACES: FR-CAT-6
let catalog = with_images(4);
let ids = image_ids(&catalog);
let c = catalog.connection();
dr_catalog::rating::set_label(c, ids[0], Some(dr_types::ColourLabel::Red)).unwrap();
dr_catalog::rating::set_label(c, ids[1], Some(dr_types::ColourLabel::Red)).unwrap();
dr_catalog::rating::set_label(c, ids[2], Some(dr_types::ColourLabel::Green)).unwrap();
let red = RatingFilter {
label: Some(dr_types::ColourLabel::Red),
..Default::default()
};
assert!(!red.is_unfiltered());
assert_eq!(read_cells_all(&catalog, &red, 0, 120).unwrap().len(), 2);
assert_eq!(total_images_filtered(&catalog, &red).unwrap(), 2);
let purple = RatingFilter {
label: Some(dr_types::ColourLabel::Purple),
..Default::default()
};
assert_eq!(total_images_filtered(&catalog, &purple).unwrap(), 0);
}
#[test]
fn a_cached_preview_is_not_a_local_original() {
// The filter answers "can I open this in develop right now", and a
+12 -3
View File
@@ -914,15 +914,24 @@ mod tests {
fn a_label_from_another_device_reaches_the_catalog_and_none_clears_nothing() {
// TRACES: FR-CAT-5
let cat = library(&["2026/a.CR2"]);
assert_eq!(apply_judgement(cat.connection(), 1, "2026/a.drsc", 0, 0, 4).unwrap(), 1);
assert_eq!(
apply_judgement(cat.connection(), 1, "2026/a.drsc", 0, 0, 4).unwrap(),
1
);
let label = |cat: &Catalog| -> Option<i64> {
cat.connection()
.query_row("SELECT label FROM versions WHERE is_default = 1", [], |r| r.get(0))
.query_row("SELECT label FROM versions WHERE is_default = 1", [], |r| {
r.get(0)
})
.unwrap()
};
assert_eq!(label(&cat), Some(4));
apply_judgement(cat.connection(), 1, "2026/a.drsc", 3, 0, 0).unwrap();
assert_eq!(label(&cat), Some(4), "a sidecar with no label left it alone");
assert_eq!(
label(&cat),
Some(4),
"a sidecar with no label left it alone"
);
}
/// The regression, in one line: a rating in a sidecar reaches the grid.
+18
View File
@@ -252,6 +252,24 @@ pub(super) fn wire_filter_ratings_and_people(window: &AppWindow, ctl: &Rc<Librar
refilter(&w, &ctl);
});
}
// TRACES: FR-CAT-6
// A colour label chip. Independent of the stars and the flag: "the red
// ones I have not starred yet" is a question worth being able to ask.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Library>()
.on_library_filter_label_changed(move |code| {
let Some(w) = weak.upgrade() else { return };
let label = dr_catalog::rating::label_from_code(Some(code as i64));
ctl.filter.borrow_mut().label = label;
w.global::<Library>()
.set_library_filter_label(label.map_or(0, |_| code));
refilter(&w, &ctl);
});
}
}
/// The date-range filter: turned on from the timeline's own visible span,
+217 -1
View File
@@ -45,11 +45,15 @@ pub fn sync_ratings(window: &AppWindow, catalog: &Catalog, ids: &[dr_types::Imag
// Absent means unrated, which is a real state rather than missing data.
let j = found.get(id).copied().unwrap_or_default();
let (rating, flag) = (j.rating as i32, flag_code(j.flag));
let label = j
.label
.map_or(0, |l| dr_catalog::rating::label_code(l) as i32);
if let Some(mut cell) = model.row_data(row) {
if cell.rating != rating || cell.flag != flag {
if cell.rating != rating || cell.flag != flag || cell.label != label {
cell.rating = rating;
cell.flag = flag;
cell.label = label;
model.set_row_data(row, cell);
}
}
@@ -78,6 +82,123 @@ pub(super) fn refresh_rating_counts(window: &AppWindow, catalog: &Catalog) {
.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32);
}
/// TRACES: FR-CAT-6
/// Refresh the label chips' counts. Separate from the star counts because
/// the two change on different events: a star key cannot move a label count,
/// so a cull does not pay for this read on every keystroke.
pub(super) fn refresh_label_counts(window: &AppWindow, catalog: &Catalog) {
let counts = dr_catalog::rating::label_histogram(catalog.connection()).unwrap_or_default();
let as_i32: Vec<i32> = counts.iter().map(|n| *n as i32).collect();
window
.global::<Library>()
.set_library_label_counts(slint::ModelRc::new(slint::VecModel::from(as_i32)));
}
/// TRACES: FR-CAT-5
/// What a label gesture asks for: a key toggles, the picker names the answer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LabelGesture {
Toggle(dr_types::ColourLabel),
Set(Option<dr_types::ColourLabel>),
}
impl LabelGesture {
/// From Slint's code and whether it came from a key. A code with no
/// colour is "none" for the picker, and nothing at all for a key.
fn from_code(code: i32, toggle: bool) -> Option<Self> {
let label = dr_catalog::rating::label_from_code(Some(code as i64));
match (toggle, label) {
(true, Some(l)) => Some(Self::Toggle(l)),
(true, None) => None,
(false, l) => Some(Self::Set(l)),
}
}
}
/// TRACES: FR-CAT-5 | FR-CAT-13
/// Label a set of images: catalog first, in one transaction, then the grid,
/// the chips and both sidecars — the order and the reasons of
/// [`apply_judgement`].
fn apply_label(
window: &AppWindow,
ctl: &Rc<LibraryController>,
images: &[dr_types::ImageId],
gesture: LabelGesture,
) {
if images.is_empty() {
window
.global::<Library>()
.set_library_status("Select an image first".into());
return;
}
let writes = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let conn = catalog.connection();
let label = match gesture {
LabelGesture::Set(l) => l,
// One read of what they carry now, for the whole set.
LabelGesture::Toggle(pressed) => {
let now = dr_catalog::rating::judgements(conn, images).unwrap_or_default();
dr_catalog::rating::toggled_label(
images.iter().map(|id| now.get(id).and_then(|j| j.label)),
pressed,
)
}
};
if let Err(e) = dr_catalog::rating::set_label_many(conn, images, label) {
window
.global::<Library>()
.set_library_error(format!("recording label: {e}").into());
return;
}
window
.global::<Library>()
.set_library_status(label_summary(images.len(), label).into());
let visible = ctl.visible_ids();
sync_ratings(window, catalog, &visible);
refresh_label_counts(window, catalog);
collect_sidecar_writes(catalog, images)
};
// A grid narrowed to red no longer holds a frame just made green.
if !ctl.filter.borrow().is_unfiltered() {
load_window(window, ctl);
}
start_sidecar_writes(window, ctl, writes);
start_xmp_writes(window, ctl, images);
}
/// What the status line says about a label that just landed — in words, as
/// every other judgement is reported.
fn label_summary(n: usize, label: Option<dr_types::ColourLabel>) -> String {
let what = match label {
Some(l) => format!("{} label", label_name(l).to_lowercase()),
None => "no label".to_string(),
};
if n == 1 {
what[..1].to_uppercase() + &what[1..]
} else {
format!("{n} images · {what}")
}
}
/// A label's name, as the chips and the picker spell it.
fn label_name(l: dr_types::ColourLabel) -> &'static str {
match l {
dr_types::ColourLabel::Red => "Red",
dr_types::ColourLabel::Yellow => "Yellow",
dr_types::ColourLabel::Green => "Green",
dr_types::ColourLabel::Blue => "Blue",
dr_types::ColourLabel::Purple => "Purple",
}
}
// --- keywords (FR-CAT-5, FR-CAT-6) ---------------------------------------
//
// `dr_catalog::keywords` owns the data rules — the vocabulary, the many-to-many
@@ -672,6 +793,7 @@ fn drain_xmp(
if let Some(catalog) = ctl.catalog.borrow().as_ref() {
sync_ratings(&w, catalog, &visible);
refresh_rating_counts(&w, catalog);
refresh_label_counts(&w, catalog);
}
refresh_xmp_conflicts(&w, ctl);
}
@@ -959,6 +1081,69 @@ pub(super) fn wire_ratings_and_flags(
}
});
}
// TRACES: FR-CAT-5 | FR-UI-5
// A label key in the grid: the photographs a rating key would reach,
// toggled as one set.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll_for_keys = coll_ctl.clone();
window
.global::<Library>()
.on_library_labelled(move |code, hovered| {
let Some(w) = weak.upgrade() else { return };
let Some(gesture) = LabelGesture::from_code(code, true) else {
return;
};
let offset = w.global::<Library>().get_library_offset().max(0) as usize;
let under_pointer = usize::try_from(hovered)
.ok()
.and_then(|ordinal| ordinal.checked_sub(offset))
.and_then(|row| ctl.visible_ids().get(row).copied());
let chosen = judged_images(coll_for_keys.selected(), under_pointer);
apply_label(&w, &ctl, &chosen, gesture);
});
}
// TRACES: FR-CAT-5 | FR-UI-4
// The selection bar's picker: the selection, set outright.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll = coll_ctl.clone();
window
.global::<Library>()
.on_library_label_chosen(move |code| {
let Some(w) = weak.upgrade() else { return };
let Some(gesture) = LabelGesture::from_code(code, false) else {
return;
};
apply_label(&w, &ctl, &coll.selected(), gesture);
});
}
// TRACES: FR-CAT-5
// One photograph by row — the one open in develop.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Library>()
.on_library_cell_labelled(move |row, code, toggle| {
let Some(w) = weak.upgrade() else { return };
let Some(gesture) = LabelGesture::from_code(code, toggle) else {
return;
};
let id = ctl
.image_ids
.borrow()
.get(row as usize)
.map(|id| dr_types::ImageId(*id as u64));
let Some(id) = id else { return };
apply_label(&w, &ctl, &[id], gesture);
});
}
}
/// TRACES: FR-UI-5
@@ -1054,6 +1239,37 @@ mod tests {
assert_eq!(judged_images(vec![], None), vec![]);
}
// --- colour labels (FR-CAT-5) -----------------------------------------
#[test]
fn a_label_key_toggles_and_the_picker_sets() {
use dr_types::ColourLabel::*;
assert_eq!(
LabelGesture::from_code(1, true),
Some(LabelGesture::Toggle(Red))
);
// A key with no colour behind it does nothing rather than clearing.
assert_eq!(LabelGesture::from_code(0, true), None);
assert_eq!(
LabelGesture::from_code(0, false),
Some(LabelGesture::Set(None))
);
assert_eq!(
LabelGesture::from_code(5, false),
Some(LabelGesture::Set(Some(Purple)))
);
}
#[test]
fn a_label_is_reported_by_name() {
// The status line names the label in words, never only by its mark.
assert_eq!(
label_summary(1, Some(dr_types::ColourLabel::Green)),
"Green label"
);
assert_eq!(label_summary(3, None), "3 images · no label");
}
// --- what the status line says about a keyword (FR-CAT-5) -------------
//
// Split out from the callback for the same reason `decide_drop` is: the
+5
View File
@@ -927,6 +927,11 @@ fn push_filter(window: &AppWindow, ctl: &Rc<LibraryController>) {
window
.global::<Library>()
.set_library_filter_eyes_open(filter.eyes_open);
window.global::<Library>().set_library_filter_label(
filter
.label
.map_or(0, |l| dr_catalog::rating::label_code(l) as i32),
);
push_people_chips(window, ctl);
}
+3 -1
View File
@@ -19,7 +19,7 @@ use crate::{AppWindow, Library, LibraryCell};
use super::controller::{stop, LibraryController, LibraryFacts};
use super::filter_bar::period_headings;
use super::offline::{collection_images, refresh_offline, scope_is_pinned};
use super::ratings_keywords::{refresh_rating_counts, sync_ratings};
use super::ratings_keywords::{refresh_label_counts, refresh_rating_counts, sync_ratings};
use super::sync::start_sweep;
use super::timeline::{civil_from_unix, format_date, refresh_timeline, restore_position};
@@ -367,6 +367,7 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
// window, not one per cell.
rating: 0,
flag: 0,
label: 0,
// And by `bursts::sync_badges`, in one more query for the
// window. Zero is "not in a burst", which is what almost every
// photograph in a library is.
@@ -424,6 +425,7 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
// so a cull still watches its own chips move.
if describes_something_new {
refresh_rating_counts(window, catalog);
refresh_label_counts(window, catalog);
}
// The library got shorter while the view was looking at it — a delete.
+4
View File
@@ -33,6 +33,7 @@ impl From<&RatingFilter> for StoredFilter {
people: f.people.clone(),
people_all: matches!(f.people_mode, PeopleMode::All),
eyes_open: f.eyes_open,
label: f.label,
}
}
}
@@ -54,6 +55,7 @@ impl From<&StoredFilter> for RatingFilter {
PeopleMode::Any
},
eyes_open: s.eyes_open,
label: s.label,
}
}
}
@@ -189,6 +191,7 @@ mod tests {
people: vec![7, 9],
people_all: true,
eyes_open: true,
label: Some(dr_types::ColourLabel::Green),
},
}
}
@@ -312,6 +315,7 @@ mod tests {
people: vec![3, 5, 8],
people_mode: PeopleMode::All,
eyes_open: true,
label: Some(dr_types::ColourLabel::Purple),
};
let back: RatingFilter = (&StoredFilter::from(&original)).into();
assert_eq!(back, original);
+15
View File
@@ -142,6 +142,21 @@ colors:
and a hue would imply it were one series among four rather than the one
the other three are decomposing.
_labels:
section: colour labels
note: |
The five colour labels (FR-CAT-5). The same exception the plot series
take: a label's name *is* a colour, so its mark is filled with it. Held
to mid-tones so one dark ink reads on every fill — the mark carries the
label's initial in `ground`, which is what keeps it legible without hue
(NFR-A11Y-3).
label-red: "#D0666E"
label-yellow: "#D6B84E"
label-green: "#72B06E"
label-blue: "#6A96D4"
label-purple: "#A988D0"
lengths:
_gaps: { break: true }
gap-sm: 6
+40
View File
@@ -1461,6 +1461,13 @@ in property <bool> panel-visible: true;
// for one fact is how they come to disagree.
viewing-trash: root.collection-selected == -1;
judged(rating, flag, at) => { Library.library-judged(rating, flag, at); }
labelled(code, at) => { Library.library-labelled(code, at); }
label-chosen(code) => { Library.library-label-chosen(code); }
filter-label: Library.library-filter-label;
label-counts: Library.library-label-counts;
filter-label-changed(code) => {
Library.library-filter-label-changed(code);
}
// `F2` renames whatever the grid is scoped to. Answered from
// the sidebar's selection rather than a second property, for
// the same reason `viewing-trash` above is: one fact, one
@@ -1497,6 +1504,14 @@ in property <bool> panel-visible: true;
fps: root.fps;
expanded: root.expanded;
filename: root.filename;
// TRACES: FR-CAT-5
// The open photograph's label, read from its grid cell so the
// bar and the grid cannot disagree; -1 with no library.
colour-label: Library.library-total > 0 && Library.library-roll-current >= 0
? Library.library-cells[Library.library-roll-current].label : -1;
label-picked(code) => {
Library.library-cell-labelled(Library.library-roll-current, code, false);
}
position: root.total > 0 ? root.index + 1 + " / " + root.total : "";
// Only where a library was opened; command-line files have no
// grid to return to.
@@ -2402,6 +2417,31 @@ in property <bool> panel-visible: true;
Masking.selected-toggled();
return accept;
}
// GESTURE: Give this photograph a colour label
// where: Develop
// touch: Tap Label in the top bar, then a
// colour
// pointer: Click Label in the top bar, then a
// colour
// keys: 6 red, 7 yellow, 8 green, 9 blue; the
// same key again takes it off
// why: The grid's keys, on the photograph
// that is open, so labelling while
// stepping through a folder is one
// hand's work. The bar names the label
// in words beside its mark.
//
// TRACES: FR-CAT-5 | FR-UI-5 | NFR-A11Y-3
// Only with a library: a file named on the command
// line has no catalog to hold a label.
if (Library.library-total > 0
&& Library.library-roll-current >= 0
&& !event.modifiers.control) {
if (event.text == "6") { Library.library-cell-labelled(Library.library-roll-current, 1, true); return accept; }
if (event.text == "7") { Library.library-cell-labelled(Library.library-roll-current, 2, true); return accept; }
if (event.text == "8") { Library.library-cell-labelled(Library.library-roll-current, 3, true); return accept; }
if (event.text == "9") { Library.library-cell-labelled(Library.library-roll-current, 4, true); return accept; }
}
// GESTURE: Move to the next or previous photograph
// where: Develop
// touch: Tap a frame in the roll along the
+34
View File
@@ -11,6 +11,7 @@ import { Theme } from "theme.slint";
import { Develop } from "session.slint";
import { Transfer } from "adjust.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel } from "widgets.slint";
import { Labels, LabelMark, LabelPicker } from "labels.slint";
// Status strip — surfaces the GPU backend and adapter, which matters during
// v0.1 because assumption A1 is exactly "does this compositing path work on
@@ -43,6 +44,13 @@ export component StatusBar inherits Rectangle {
/// Whether the edit can be stepped either way (FR-DEV-5).
in property <bool> can-undo: false;
in property <bool> can-redo: false;
/// TRACES: FR-CAT-5
/// The open photograph's colour label code, 0 for none, -1 where there
/// is no library to hold one (files named on the command line).
in property <int> colour-label: -1;
/// Whether the label choices are open in the strip.
property <bool> picking-label: false;
callback label-picked(int);
callback back-to-library();
callback open-people();
@@ -115,6 +123,32 @@ export component StatusBar inherits Rectangle {
Caption { text: root.position; }
// TRACES: FR-CAT-5 | FR-UI-4 | NFR-A11Y-3
// The label, shown and set here. The button says the label's name
// beside its mark, so the strip reads "Label: Green" in words; with
// none it offers "Label". Pressing it opens the six choices in the
// strip, which close on the one chosen.
Button {
text: root.colour-label > 0 ? "Label: " + Labels.names[root.colour-label] : "Label";
active: root.picking-label;
visible: root.colour-label >= 0;
y: (parent.height - self.height) / 2;
clicked => { root.picking-label = !root.picking-label; }
}
if root.colour-label > 0: LabelMark {
code: root.colour-label;
y: (parent.height - self.height) / 2;
}
if root.picking-label && root.colour-label >= 0: LabelPicker {
current: root.colour-label;
chosen(code) => {
root.label-picked(code);
root.picking-label = false;
}
}
Rectangle { horizontal-stretch: 1; }
// --- instrumentation lives in Settings now ------------------------
+131
View File
@@ -0,0 +1,131 @@
// TRACES: FR-CAT-5 | NFR-A11Y-3
// Colour labels: the mark that shows one, and the row that chooses one.
//
// **A label is named, not only tinted.** NFR-A11Y-3 names colour labels as
// the first thing that must not be told apart by hue alone, and a colour
// label is the one status here whose *name* is a colour — so the temptation
// is exactly a swatch. Every mark therefore carries the label's initial
// (R, Y, G, B, P) in dark ink on the fill, and every place a label can be
// chosen or filtered on says its name in words. Red and green are the pair
// the commonest colour-vision deficiency merges, and they are "R" and "G"
// here before they are anything else.
//
// The integers are `versions.label`'s own codes (`dr_catalog::rating::
// label_code`): 0 for none, 1 red, 2 yellow, 3 green, 4 blue, 5 purple. One
// numbering from the column to the pixel, so nothing translates between them.
import { Theme } from "theme.slint";
/// TRACES: FR-CAT-5 | NFR-A11Y-3
/// What each label code is called, and the letter its mark carries.
export global Labels {
/// Indexed by code. "None" at 0 is what the picker's clearing choice says.
out property <[string]> names: ["None", "Red", "Yellow", "Green", "Blue", "Purple"];
/// The glyph on the mark, indexed by code. Distinct for every label: this
/// is what carries the label for an eye that does not separate the fills.
out property <[string]> letters: ["", "R", "Y", "G", "B", "P"];
/// The fill for a code. Data rather than chrome, the same exception the
/// histogram's traces take (see `style.yaml`): the label *is* a colour.
public pure function fill(code: int) -> color {
if (code == 1) { return Theme.label-red; }
if (code == 2) { return Theme.label-yellow; }
if (code == 3) { return Theme.label-green; }
if (code == 4) { return Theme.label-blue; }
if (code == 5) { return Theme.label-purple; }
return transparent;
}
}
/// TRACES: FR-CAT-5 | NFR-A11Y-3
/// One label, drawn: a rounded tag in the label's colour with its initial
/// on it. Invisible for code 0, so a caller can place it unconditionally.
export component LabelMark inherits Rectangle {
in property <int> code: 0;
in property <length> size: 16px;
accessible-role: text;
accessible-label: Labels.names[root.code] + " label";
visible: root.code > 0 && root.code <= 5;
width: root.size;
height: root.size;
border-radius: root.size / 4;
background: Labels.fill(root.code);
// A rim in the ground colour, so a yellow tag on a pale sky still has an
// edge.
border-width: 1px;
border-color: Theme.ground;
Text {
// The letter is the label. Dark ink on every fill: the fills are held
// to mid-tones in `style.yaml` so one ink reads on all five.
text: Labels.letters[root.code];
color: Theme.ground;
font-size: root.size * 0.62;
font-weight: 800;
width: 100%;
height: 100%;
horizontal-alignment: center;
vertical-alignment: center;
}
}
/// TRACES: FR-CAT-5 | FR-UI-4 | NFR-A11Y-3
/// The six choices — none, then the five labels — each a mark and a word.
///
/// Inline rather than a popup: it opens in the bar that asked for it, where
/// a finger already is, and there is no second surface to dismiss.
export component LabelPicker inherits HorizontalLayout {
/// The code currently carried, drawn as pressed. -1 where the photographs
/// being labelled disagree, so nothing is shown as chosen.
in property <int> current: -1;
callback chosen(int);
spacing: 4px;
for code in [0, 1, 2, 3, 4, 5]: Rectangle {
accessible-role: button;
accessible-label: code == 0 ? "No label" : Labels.names[code] + " label";
accessible-checkable: true;
accessible-checked: root.current == code;
accessible-action-default => { root.chosen(code); }
height: Theme.control-height - 4px;
width: row.preferred-width + 2 * Theme.gap-sm;
y: (parent.height - self.height) / 2;
border-radius: Theme.radius;
border-width: 1px;
border-color: root.current == code ? Theme.active : Theme.rule;
background: touch.pressed ? Theme.pressed
: (touch.has-hover ? Theme.hover : Theme.surface-raised);
touch := TouchArea {
width: 100%;
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
mouse-cursor: pointer;
clicked => { root.chosen(code); }
}
row := HorizontalLayout {
padding-left: Theme.gap-sm;
padding-right: Theme.gap-sm;
spacing: 4px;
if code > 0: LabelMark {
code: code;
size: 14px;
y: (parent.height - self.height) / 2;
}
Text {
text: Labels.names[code];
color: Theme.ink;
font-size: Theme.text-sm;
vertical-alignment: center;
}
}
}
}
+134 -7
View File
@@ -15,6 +15,7 @@ import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, Prog
// too many.
import { CollectionRow } from "collections.slint";
import { GestureSheet, GestureRow } from "gestures.slint";
import { Labels, LabelMark, LabelPicker } from "labels.slint";
// TRACES: FR-CAT-5
// One keyword in the keywording sheet, already answered against the selection.
@@ -462,6 +463,10 @@ export struct LibraryCell {
// 0 unflagged, 1 pick, 2 reject. Independent of the stars: rejecting a
// four-star frame is a normal thing to do mid-cull.
flag: int,
// TRACES: FR-CAT-5
// The colour label: 0 none, 1..5 red, yellow, green, blue, purple —
// `versions.label`'s own codes (see labels.slint).
label: int,
// Frames in the burst this cell belongs to (FR-CULL-5), itself included; 0 where it
// belongs to none, which is most of a library. A burst that is collapsed
// draws only its representative, so on that cell this is the count of what
@@ -640,6 +645,26 @@ export global Library {
/// cell under the pointer, or -1: Rust applies the key to that photograph
/// alone unless it is part of the selection (darktable's rule).
callback library-judged(int, int, int);
/// TRACES: FR-CAT-5 | FR-UI-5
/// A label key (6-9) was pressed in the grid: the label code, then the
/// ordinal under the pointer or -1, which reaches the same photographs a
/// rating key would. The key toggles — see `dr_catalog::rating::
/// toggled_label`.
callback library-labelled(int, int);
/// TRACES: FR-CAT-5 | FR-UI-4
/// A label was chosen from the selection bar's picker, 0 for none. Sets
/// rather than toggles: the picker names the answer outright.
callback library-label-chosen(int);
/// TRACES: FR-CAT-5
/// One photograph by row, from develop: the code, and whether it toggles
/// (a key) or sets (the picker).
callback library-cell-labelled(int, int, bool);
/// TRACES: FR-CAT-6
/// The label the grid is narrowed to, 0 for none; and the images behind
/// each label, index 0 unlabelled, for the chips.
in property <int> library-filter-label: 0;
in-out property <[int]> library-label-counts;
callback library-filter-label-changed(int);
in property <int> library-filter-min-rating: 0;
/// TRACES: FR-UI-5
/// The top of a star range, 5 where there is none, and the F chord that
@@ -1519,6 +1544,15 @@ export component LibraryGrid inherits Rectangle {
/// star, so neither axis is disturbed by a press on the other. The third
/// is `hover-ordinal`: which photograph the pointer is over, if any.
callback judged(int, int, int);
/// TRACES: FR-CAT-5 | FR-UI-5
/// A label key, with `hover-ordinal` as for `judged`.
callback labelled(int, int);
/// TRACES: FR-CAT-5 | FR-UI-4
/// A label chosen from the selection bar, 0 for none.
callback label-chosen(int);
/// Whether the selection bar's label picker is open. Local, like
/// `keywording`.
property <bool> labelling: false;
/// TRACES: FR-UI-5
/// The library ordinal of the cell under the pointer, -1 when none.
@@ -1810,6 +1844,11 @@ export component LibraryGrid inherits Rectangle {
callback filter-min-rating-changed(int);
callback filter-unjudged-toggled(bool);
callback filter-flag-changed(int);
/// TRACES: FR-CAT-6
/// The colour label narrowed to, 0 for none, and each label's count.
in property <int> filter-label: 0;
in property <[int]> label-counts;
callback filter-label-changed(int);
// --- offline (FR-CAT-9) -------------------------------------------------
//
@@ -2342,7 +2381,7 @@ export component LibraryGrid inherits Rectangle {
// Hidden while there is nothing to filter: an empty library offering
// six rating buttons is chrome describing data that does not exist.
if root.total > 0 || root.filter-min-rating > 0 || root.filter-unjudged
|| root.filter-flag > 0 || root.filter-people.length > 0
|| root.filter-flag > 0 || root.filter-label > 0 || root.filter-people.length > 0
|| root.filter-eyes-open: Rectangle {
height: 34px;
background: Theme.surface;
@@ -2481,12 +2520,14 @@ export component LibraryGrid inherits Rectangle {
FilterChip {
label: "All";
active: root.filter-min-rating == 0 && root.filter-max-rating >= 5
&& !root.filter-unjudged && root.filter-flag == 0;
&& !root.filter-unjudged && root.filter-flag == 0
&& root.filter-label == 0;
y: (parent.height - self.height) / 2;
clicked => {
root.filter-rating-range(0, 5);
root.filter-unjudged-toggled(false);
root.filter-flag-changed(0);
root.filter-label-changed(0);
}
}
@@ -2558,6 +2599,32 @@ export component LibraryGrid inherits Rectangle {
Rectangle { width: Theme.gap; }
// GESTURE: Show only photographs with one colour label
// where: Library grid
// touch: Tap its chip in the filter bar
// pointer: Click its chip in the filter bar
// why: Each chip is the label's mark and its name, so
// the one you want is found by reading it; tap
// the lit chip again to show every label.
//
// TRACES: FR-CAT-6 | NFR-A11Y-3
// One chip per colour label, each the cell's own mark and the
// label's name — never a bare swatch, which is the control
// NFR-A11Y-3 was written against. One label at a time, and
// pressing the lit one clears it, as the flag chips do.
for n in [1, 2, 3, 4, 5]: FilterChip {
colour-label: n;
label: Labels.names[n];
count: root.label-counts.length > n ? root.label-counts[n] : -1;
active: root.filter-label == n;
y: (parent.height - self.height) / 2;
clicked => {
root.filter-label-changed(root.filter-label == n ? 0 : n);
}
}
Rectangle { width: Theme.gap; }
// Locally-stored originals. Always offered, not only when
// offline: "what can I actually work on right now" is a fair
// question on a slow connection too, and a control that
@@ -2597,7 +2664,8 @@ export component LibraryGrid inherits Rectangle {
// single most confusing state a filter can leave behind.
Caption {
text: (root.filter-min-rating > 0 || root.filter-unjudged
|| root.filter-flag > 0 || root.local-only
|| root.filter-flag > 0 || root.filter-label > 0
|| root.local-only
|| root.filter-eyes-open)
? "filtered" : "";
emphasised: true;
@@ -2980,6 +3048,10 @@ export component LibraryGrid inherits Rectangle {
root.keywording = false;
return accept;
}
if (root.labelling) {
root.labelling = false;
return accept;
}
if (root.filing) {
root.filing = false;
return accept;
@@ -3085,6 +3157,29 @@ export component LibraryGrid inherits Rectangle {
if (event.text == "3") { root.judged(3, -1, root.hover-ordinal); return accept; }
if (event.text == "4") { root.judged(4, -1, root.hover-ordinal); return accept; }
if (event.text == "5") { root.judged(5, -1, root.hover-ordinal); return accept; }
// GESTURE: Give photographs a colour label
// where: Library grid
// touch: Select them, then Label in the selection bar
// and tap a colour
// pointer: Select them, then Label in the selection bar
// and click a colour
// keys: 6 red, 7 yellow, 8 green, 9 blue, with the
// pointer over it or on the selection; the
// same key again takes the label off
// why: Lightroom's keys, so hands that learned them
// there need not learn them again. Purple has
// no key there either, and is on the bar. Every
// mark carries its label's initial, so the
// label is read without telling the colours
// apart.
//
// TRACES: FR-CAT-5 | FR-UI-5 | NFR-A11Y-3
// After the F chord above, which has returned for its own
// digits (0-5 only), so these never filter.
if (event.text == "6") { root.labelled(1, root.hover-ordinal); return accept; }
if (event.text == "7") { root.labelled(2, root.hover-ordinal); return accept; }
if (event.text == "8") { root.labelled(3, root.hover-ordinal); return accept; }
if (event.text == "9") { root.labelled(4, root.hover-ordinal); return accept; }
// Case-insensitive: caps lock during a long cull must not
// silently stop the keys working.
if (event.text == "p" || event.text == "P") {
@@ -3629,10 +3724,25 @@ export component LibraryGrid inherits Rectangle {
}
Label {
text: cell.name;
emphasised: cell.selected;
overflow: elide;
// TRACES: FR-CAT-5 | NFR-A11Y-3
// The colour label leads the caption: on the
// photograph it would compete with the flag and the
// badges for four corners, and here it is read with
// the name, in the row every cell already has.
HorizontalLayout {
spacing: 4px;
if cell.label > 0: LabelMark {
code: cell.label;
size: 14px;
y: (parent.height - self.height) / 2;
}
Label {
text: cell.name;
emphasised: cell.selected;
overflow: elide;
}
}
}
@@ -4734,6 +4844,23 @@ export component LibraryGrid inherits Rectangle {
}
}
// TRACES: FR-CAT-5 | FR-UI-4
// Label the selection. Opens the six choices in the bar itself —
// a mark and a name each — and closes on the one chosen.
if !root.ranging && root.selected-count > 0: Button {
text: "Label";
active: root.labelling;
y: (parent.height - self.height) / 2;
clicked => { root.labelling = !root.labelling; }
}
if !root.ranging && root.selected-count > 0 && root.labelling: LabelPicker {
chosen(code) => {
root.label-chosen(code);
root.labelling = false;
}
}
// TRACES: FR-DEV-6
// Paste the copied edit onto the selection. One of the things a
// selection can have done to it, like filing it in a collection,
+13
View File
@@ -31,6 +31,7 @@
import { Theme } from "theme.slint";
import { Icon } from "icons.slint";
import { LabelMark } from "labels.slint";
export { Icon }
@@ -267,6 +268,12 @@ export component FilterChip inherits Rectangle {
/// Drawn rather than prefixed to `label`, because a symbol inside a string
/// is exactly the thing [`Icon`] exists to stop (see icons.slint).
in property <string> icon;
/// TRACES: FR-CAT-6 | NFR-A11Y-3
/// A colour label's mark before the word, for the label chips — the
/// same mark the grid cell carries, so the chip and the photographs it
/// narrows to are visibly one thing. 0 for none. The word stays: the
/// mark's letter is the fallback for the eye, the name is the chip.
in property <int> colour-label: 0;
callback clicked();
@@ -323,6 +330,12 @@ export component FilterChip inherits Rectangle {
y: (parent.height - self.height) / 2;
}
if root.colour-label > 0: LabelMark {
code: root.colour-label;
size: 14px;
y: (parent.height - self.height) / 2;
}
Text {
text: root.label;
// Dark on the active fill, which is near-white — the same