Rate under the pointer, filter a star range, and name Help as help
Benchmarks / CPU and I/O (per commit) (push) Successful in 1m53s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 2m50s
Build and test / Layer separation (push) Successful in 33s
Traceability / Requirement traces (push) Successful in 40s
🐳 Android image / Build and push (push) Successful in 4s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 4s
Build and test / windows-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Successful in 14m19s
Build and test / Windows (x86_64, cross) (push) Successful in 17m50s
Build and test / Publish the release (push) Skipped

Rating keys in the grid follow darktable's rule: with the pointer over a
photograph outside the selection, 0-5, P, X and U judge that photograph
alone; over one inside it, the whole selection, as before; off the grid,
the selection. The hover is cleared when the grid scrolls, so a key after
a wheel turn cannot judge whatever used to be under the pointer.

Holding F and tapping digits filters by stars: one digit for exactly
that many, two for everything between them, F alone to show every
rating again. The filter gains a ceiling to do it (`max_rating`, one
BETWEEN in the query). The place record carries it, and a record from an
older build reads as having none. The star chips light across a capped
range and the bar says "2-3★ only" beside them.

The grid also takes Ctrl+E and Ctrl+Shift+E for the selection, Ctrl+V to
paste onto it and Ctrl+A to select all. The "Gestures" button is now
"Help", its sheet "Controls and shortcuts", and F1 opens it.
This commit is contained in:
2026-09-24 05:11:36 +02:00
parent bddf3250c5
commit 00c028c8c8
12 changed files with 487 additions and 102 deletions
+7 -1
View File
@@ -1385,6 +1385,8 @@ in property <bool> panel-visible: true;
can-import: root.import-supported;
open-import() => { root.import-open(); }
open-settings() => { root.settings-open(); }
open-export-options() => { root.export-options-open(); }
sheet-open: root.export-sheet-open;
open-people() => { root.identity-open(); }
cell-pressed(i, ctrl, shift) => {
@@ -1424,6 +1426,10 @@ in property <bool> panel-visible: true;
people: Library.library-people;
gestures: Library.library-gestures;
filter-min-rating: Library.library-filter-min-rating;
filter-max-rating: Library.library-filter-max-rating;
filter-rating-range(low, high) => {
Library.library-filter-rating-range(low, high);
}
filter-unjudged: Library.library-filter-unjudged;
filter-flag: Library.library-filter-flag;
rating-counts: Library.library-rating-counts;
@@ -1440,7 +1446,7 @@ in property <bool> panel-visible: true;
// for the trash row (see collections.slint), and two sources
// for one fact is how they come to disagree.
viewing-trash: root.collection-selected == -1;
judged(rating, flag) => { Library.library-judged(rating, flag); }
judged(rating, flag, at) => { Library.library-judged(rating, flag, at); }
// `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
+1 -1
View File
@@ -103,7 +103,7 @@ export component GestureSheet inherits Rectangle {
// The book covers every place, and opens on Develop — a
// title naming the grid was wrong about the first thing
// under it.
text: "How to drive DarkRoom";
text: "Controls and shortcuts";
color: Theme.ink;
font-size: Theme.text-lg;
font-weight: 600;
+212 -24
View File
@@ -635,10 +635,17 @@ export global Library {
callback library-burst-representative-chosen(int);
/// Move the grid selection to the trash — the `Delete` key.
callback library-trash-selection();
/// A judgement key was pressed, applying to the whole selection. One of
/// the two arguments is -1, saying which axis was *not* meant.
callback library-judged(int, int);
/// A judgement key was pressed. One of the first two arguments is -1,
/// saying which axis was *not* meant. The third is the ordinal of the
/// 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);
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
/// sets both ends at once.
in property <int> library-filter-max-rating: 5;
callback library-filter-rating-range(int, int);
in property <bool> library-filter-unjudged: false;
in property <int> library-filter-flag: 0;
/// Images per star count, index 0 unrated, for the filter chips.
@@ -1326,8 +1333,10 @@ component HeaderActions inherits HorizontalLayout {
// the photographs, and unconditional for the same reason Settings is: a
// reference you can only reach in some states is one you look for in the
// state where you needed it and do not find.
// "Help" rather than "Gestures": most of what it lists is now keys, and
// a reader looking for a shortcut does not think to look under gestures.
Button {
text: "Gestures";
text: "Help";
y: root.centred ? (root.row-height - self.height) / 2 : 0;
clicked => { root.open-gestures(); }
}
@@ -1507,8 +1516,18 @@ export component LibraryGrid inherits Rectangle {
/// Rating and flag travel on one callback because they are one keystroke
/// as far as the user is concerned; Rust decodes which axis was meant.
/// `rating` is -1 where the key was a flag, and `flag` -1 where it was a
/// star, so neither axis is disturbed by a press on the other.
callback judged(int, int);
/// 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-UI-5
/// The library ordinal of the cell under the pointer, -1 when none.
///
/// An ordinal and not a row of the loaded window, because the window moves
/// under a still pointer as the grid scrolls; and cleared when it scrolls,
/// so a key pressed after a wheel turn falls back to the selection rather
/// than judging whatever used to be under the pointer.
property <int> hover-ordinal: -1;
/// `F2` — rename the collection the grid is scoped to. The key lives with
/// the grid because that is what holds focus in library mode, but the
/// rename itself happens in the sidebar.
@@ -1743,6 +1762,10 @@ export component LibraryGrid inherits Rectangle {
// thumbnail, which is the cost FR-NC-3 exists to avoid.
/// Minimum stars to show. 0 shows everything.
in property <int> filter-min-rating: 0;
/// TRACES: FR-UI-5
/// The top of a star range; 5 is none.
in property <int> filter-max-rating: 5;
callback filter-rating-range(int, int);
/// Show only images nothing has judged yet — FR-CULL-4's "filter to
/// unjudged", which is what lets a culling session resume.
in property <bool> filter-unjudged: false;
@@ -1863,6 +1886,11 @@ export component LibraryGrid inherits Rectangle {
in property <bool> export-to-server: false;
callback export-selection();
callback cancel-export();
/// TRACES: FR-EXP-7 | FR-UI-5
/// The export sheet, which the window draws: Ctrl+E.
callback open-export-options();
/// A sheet is drawn over the grid, so the keys here stand down.
in property <bool> sheet-open: false;
// TRACES: FR-MRG-1
// Merge the selection into a panorama. Two frames at least; the page
// that opens says what it found and asks before anything is written.
@@ -2452,11 +2480,11 @@ export component LibraryGrid inherits Rectangle {
// the two read as the same scale.
FilterChip {
label: "All";
active: root.filter-min-rating == 0 && !root.filter-unjudged
&& root.filter-flag == 0;
active: root.filter-min-rating == 0 && root.filter-max-rating >= 5
&& !root.filter-unjudged && root.filter-flag == 0;
y: (parent.height - self.height) / 2;
clicked => {
root.filter-min-rating-changed(0);
root.filter-rating-range(0, 5);
root.filter-unjudged-toggled(false);
root.filter-flag-changed(0);
}
@@ -2478,16 +2506,34 @@ export component LibraryGrid inherits Rectangle {
icon: "star";
label: n + "+";
count: root.rating-counts.length > n ? root.rating-counts[n] : -1;
active: root.filter-min-rating == n;
// Under a capped range (the F chord), every chip inside
// it is lit, so "2 and 3 only" reads as two chips rather
// than as the "2+" it is not.
active: root.filter-max-rating < 5
? (n >= root.filter-min-rating && n <= root.filter-max-rating)
: root.filter-min-rating == n;
y: (parent.height - self.height) / 2;
// Pressing the active one clears it, so the filter is its
// own undo and "All" is not the only way back.
clicked => {
root.filter-min-rating-changed(
root.filter-min-rating == n ? 0 : n);
root.filter-min-rating == n && root.filter-max-rating >= 5
? 0 : n);
}
}
// TRACES: FR-UI-5
// A ceiling is the one star filter the chips cannot state, so
// it is stated in words while it holds.
if root.filter-max-rating < 5: Caption {
text: root.filter-max-rating == 0 && root.filter-min-rating == 0
? "no stars only"
: (root.filter-min-rating == root.filter-max-rating
? root.filter-min-rating + "★ only"
: root.filter-min-rating + "–" + root.filter-max-rating + "★ only");
vertical-alignment: center;
}
Rectangle { width: Theme.gap; }
FilterChip {
@@ -2884,10 +2930,33 @@ export component LibraryGrid inherits Rectangle {
// the Flickable competes with it for the arrow keys. This holds
// focus and forwards nothing else.
//
// Applies to the **selection**, not to a cell under the pointer —
// that is what makes rating forty frames a single keystroke, and it
// matches what the header's count says is selected.
// Applies to the cell under the pointer when that cell is outside
// the selection, and to the **selection** otherwise — darktable's
// rule. Hovering one of forty selected frames still rates all
// forty in one keystroke; hovering a frame nobody selected rates
// that frame without disturbing the selection. Rust decides, from
// `hover-ordinal`.
judge-keys := FocusScope {
// TRACES: FR-UI-5
// The F chord: held, and the digit that arrived first while
// it was, or -1. See `key-released` and the digits below.
property <bool> f-held: false;
property <int> f-first: -1;
property <bool> f-used: false;
key-released(event) => {
// Tapping F on its own clears the star filter; having
// tapped digits under it, letting go only ends the chord.
if (self.f-held && (event.text == "f" || event.text == "F")) {
if (!self.f-used) {
root.filter-rating-range(0, 5);
}
self.f-held = false;
return accept;
}
return reject;
}
height: 0px;
// The grid is the primary surface of this screen, so it takes
// focus on show rather than waiting for a click. Without this
@@ -2895,6 +2964,11 @@ export component LibraryGrid inherits Rectangle {
init => { self.focus(); }
key-pressed(event) => {
// Declined, not swallowed, so Escape reaches the shell
// and closes the sheet rather than judging under it.
if (root.sheet-open) {
return reject;
}
// TRACES: FR-UI-2 | FR-UI-4
// Back and Escape close what is open here, innermost
// first, before the shell above gets to read them as
@@ -2916,24 +2990,113 @@ export component LibraryGrid inherits Rectangle {
}
return reject;
}
if (event.text == "0") { root.judged(0, -1); return accept; }
if (event.text == "1") { root.judged(1, -1); return accept; }
if (event.text == "2") { root.judged(2, -1); return accept; }
if (event.text == "3") { root.judged(3, -1); return accept; }
if (event.text == "4") { root.judged(4, -1); return accept; }
if (event.text == "5") { root.judged(5, -1); return accept; }
// GESTURE: Export the selection as the last export was
// where: Library grid
// touch: Select them, then Export in the selection bar
// pointer: Select them, then Export in the selection bar
// keys: Ctrl+Shift+E, or Ctrl+E to see the export
// settings first
// why: 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.
//
// TRACES: FR-EXP-7 | FR-UI-5
// Before the bare keys below: a chord must not also be read
// as the letter it is spelled with.
if (event.modifiers.control
&& (event.text == "e" || event.text == "E")) {
if (event.modifiers.shift) {
root.export-selection();
} else {
root.open-export-options();
}
return accept;
}
// GESTURE: Paste copied settings onto the selection
// where: Library grid
// touch: Select them, then "Paste to N" in the
// selection bar
// pointer: Select them, then "Paste to N"
// keys: Ctrl+V
//
// TRACES: FR-DEV-6 | FR-UI-5
// Silent with nothing copied, as the button is disabled.
if (event.modifiers.control && root.settings-armed
&& (event.text == "v" || event.text == "V")) {
root.paste-settings-to-selection();
return accept;
}
// TRACES: FR-CAT-5 | FR-UI-5
// Ctrl+A takes everything the grid shows, as "Select all"
// in the selection strip does.
if (event.modifiers.control
&& (event.text == "a" || event.text == "A")) {
root.select-all();
return accept;
}
// GESTURE: Show only photographs with some number of stars
// where: Library grid
// touch: Tap a star chip in the filter bar
// 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
// why: 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.
//
// Held rather than typed in sequence, so nothing waits
// to find out whether a second digit is coming: every
// digit filters as it lands. Any other key ends the chord,
// in case its release went to a window that had focus by
// then and the grid never heard it.
if (!event.modifiers.control
&& (event.text == "f" || event.text == "F")) {
// Key repeat while held says nothing new.
if (!self.f-held) {
self.f-held = true;
self.f-first = -1;
self.f-used = false;
}
return accept;
}
if (self.f-held) {
if (event.text == "0" || event.text == "1"
|| event.text == "2" || event.text == "3"
|| event.text == "4" || event.text == "5") {
if (self.f-first < 0) {
self.f-first = event.text.to-float();
root.filter-rating-range(self.f-first, self.f-first);
} else {
root.filter-rating-range(self.f-first,
event.text.to-float());
}
self.f-used = true;
return accept;
}
self.f-held = false;
}
if (event.text == "0") { root.judged(0, -1, root.hover-ordinal); return accept; }
if (event.text == "1") { root.judged(1, -1, root.hover-ordinal); return accept; }
if (event.text == "2") { root.judged(2, -1, root.hover-ordinal); return accept; }
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; }
// Case-insensitive: caps lock during a long cull must not
// silently stop the keys working.
if (event.text == "p" || event.text == "P") {
root.judged(-1, 1);
root.judged(-1, 1, root.hover-ordinal);
return accept;
}
if (event.text == "x" || event.text == "X") {
root.judged(-1, 2);
root.judged(-1, 2, root.hover-ordinal);
return accept;
}
if (event.text == "u" || event.text == "U") {
root.judged(-1, 0);
root.judged(-1, 0, root.hover-ordinal);
return accept;
}
// Delete moves the selection to the trash folder on the
@@ -2949,6 +3112,13 @@ export component LibraryGrid inherits Rectangle {
// rename key everywhere else, and the reason it is bound
// here is that this scope is what holds focus in library
// mode. Rust ignores it when nothing is scoped.
// TRACES: FR-UI-4 | FR-UI-5
// F1 opens the reference the Help button does — the help
// key everywhere else.
if (event.text == Key.F1) {
root.helping = true;
return accept;
}
if (event.text == Key.F2) {
root.rename-scope();
return accept;
@@ -3039,6 +3209,9 @@ export component LibraryGrid inherits Rectangle {
// does not gate it.
if root.total > 0: grid-scroll := Flickable {
interactive: root.held-row < 0;
// See `hover-ordinal`: the cell under a still pointer changes
// as this moves, and nothing re-announces the hover.
changed viewport-y => { root.hover-ordinal = -1; }
// Ctrl+wheel resizes the cells; a plain wheel is declined and
// falls through to the Flickable's own scrolling. Two jobs on
// one gesture, distinguished by the modifier — the convention
@@ -3505,6 +3678,19 @@ export component LibraryGrid inherits Rectangle {
cell-touch := TouchArea {
mouse-cursor: pointer;
// TRACES: FR-UI-5
// Which photograph a judgement key would reach
// without a selection. Cleared only by the cell that
// set it, so moving from one cell to the next cannot
// be undone by the old cell's leave arriving second.
changed has-hover => {
if (self.has-hover) {
root.hover-ordinal = i + root.offset;
} else if (root.hover-ordinal == i + root.offset) {
root.hover-ordinal = -1;
}
}
// Selected on *press*, not on release: the drag that may
// follow reads the selection to build its payload, and by
// release the pointer is over the sidebar.
@@ -3658,7 +3844,8 @@ export component LibraryGrid inherits Rectangle {
// where: Library grid
// touch: Tap a star on the cell
// pointer: Hover the cell, then click a star
// keys: 0 to 5 on the selection
// keys: 0 to 5 with the pointer over it, or
// on the selection
// why: A star has to take the press without it
// also reaching the cell, or every rating
// throws the user into develop.
@@ -4482,6 +4669,7 @@ export component LibraryGrid inherits Rectangle {
// where: Library grid
// touch: While selecting, press "Select all"
// pointer: While selecting, press "Select all"
// keys: Ctrl+A
// why: A scoped grid of two hundred frames is two hundred
// taps otherwise, and "all of them, except those
// three" is a far more common shape than the taps it