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
+23 -2
View File
@@ -274,6 +274,27 @@ 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: "Export the selection as the last export was",
section: "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",
},
Gesture {
title: "Paste copied settings onto the selection",
section: "Library grid",
touch: "Select them, then \"Paste to N\" in the selection bar",
pointer: "Select them, then \"Paste to N\"",
keys: "Ctrl+V",
},
Gesture {
title: "Show only photographs with some number of stars",
section: "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",
},
Gesture {
title: "Resize the thumbnails",
section: "Library grid",
@@ -300,7 +321,7 @@ pub const GESTURES: &[Gesture] = &[
section: "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",
},
Gesture {
title: "Choose the frame a folded burst shows",
@@ -321,7 +342,7 @@ pub const GESTURES: &[Gesture] = &[
section: "Library grid",
touch: "While selecting, press \"Select all\"",
pointer: "While selecting, press \"Select all\"",
keys: "",
keys: "Ctrl+A",
},
Gesture {
title: "Take photographs out of a collection",
+43 -6
View File
@@ -76,6 +76,11 @@ pub(super) const TRASH_ORDER: &str = "ORDER BY i.trashed_at DESC, i.source_ref A
pub struct RatingFilter {
/// Minimum stars. 0 means no star constraint.
pub min_rating: u8,
/// TRACES: FR-UI-5
/// Maximum stars, for a range — `None` for no ceiling. Set by holding F
/// and tapping two digits in the grid; the filter bar's chips set only
/// the floor and clear this.
pub max_rating: Option<u8>,
/// Only images nothing has judged yet — neither starred nor flagged.
/// This is what lets a culling session resume where it stopped.
pub unjudged: bool,
@@ -175,6 +180,7 @@ impl RatingFilter {
/// Whether this narrows anything, so the caller can skip the join.
pub fn is_unfiltered(&self) -> bool {
self.min_rating == 0
&& self.max_rating.is_none()
&& !self.unjudged
&& self.flag.is_none()
&& !self.local_only
@@ -216,13 +222,15 @@ impl RatingFilter {
pub(super) fn sql(&self) -> String {
let mut terms = Vec::new();
if self.min_rating > 0 {
terms.push(format!(
"coalesce((SELECT dv.rating FROM versions dv
// One subquery for both ends of a range, not one per end: it is a
// correlated read per image either way, and two would be two.
let stars = "coalesce((SELECT dv.rating FROM versions dv
WHERE dv.image_id = i.id AND dv.is_default = 1
LIMIT 1), 0) >= {}",
self.min_rating
));
LIMIT 1), 0)";
match (self.min_rating, self.max_rating) {
(0, None) => {}
(min, None) => terms.push(format!("{stars} >= {min}")),
(min, Some(max)) => terms.push(format!("{stars} BETWEEN {min} AND {max}")),
}
// Integers this code owns, formatted straight in like the rating terms
@@ -453,6 +461,35 @@ mod tests {
assert_eq!(total_images_filtered(&catalog, &filter).unwrap(), 2);
}
// --- star ranges (FR-UI-5) -------------------------------------------
#[test]
fn a_star_range_keeps_both_ends_and_drops_the_rest() {
// Hold F, tap 2 then 3: the twos and threes, not the fours above them
// and not the unrated below.
let catalog = with_images(6);
let ids = image_ids(&catalog);
for (id, stars) in ids.iter().zip([1u8, 2, 3, 4, 5]) {
dr_catalog::rating::set_rating(catalog.connection(), *id, stars).unwrap();
}
let range = RatingFilter {
min_rating: 2,
max_rating: Some(3),
..Default::default()
};
assert_eq!(read_cells_all(&catalog, &range, 0, 120).unwrap().len(), 2);
assert_eq!(total_images_filtered(&catalog, &range).unwrap(), 2);
// A ceiling with no floor reaches down to the unrated.
let no_stars = RatingFilter {
max_rating: Some(0),
..Default::default()
};
assert!(!no_stars.is_unfiltered());
assert_eq!(total_images_filtered(&catalog, &no_stars).unwrap(), 1);
}
#[test]
fn an_empty_cache_is_not_an_empty_library() {
// The unfiltered grid must not depend on the cache table having rows —
+38
View File
@@ -36,6 +36,10 @@ pub(super) fn wire_filter_ratings_and_people(window: &AppWindow, ctl: &Rc<Librar
.on_library_filter_min_rating_changed(move |n| {
let Some(w) = weak.upgrade() else { return };
ctl.filter.borrow_mut().min_rating = n.clamp(0, 5) as u8;
// The chips say "n or more", so a chip replaces a range
// rather than narrowing it from below.
ctl.filter.borrow_mut().max_rating = None;
w.global::<Library>().set_library_filter_max_rating(5);
// Stars and "unrated" are contradictory terms — asking for four
// stars *and* nothing judged matches nothing at all, which reads
// as a broken filter rather than an impossible question.
@@ -50,6 +54,38 @@ pub(super) fn wire_filter_ratings_and_people(window: &AppWindow, ctl: &Rc<Librar
});
}
// TRACES: FR-UI-5
// A star range from the keyboard: hold F and tap one digit for exactly
// that many stars, two for everything between them. Five as the ceiling
// is no ceiling, so "F, 2, 5" and the "2+" chip are the same filter and
// the bar shows them the same way.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Library>()
.on_library_filter_rating_range(move |low, high| {
let Some(w) = weak.upgrade() else { return };
let (low, high) = (low.clamp(0, 5), high.clamp(0, 5));
let (low, high) = (low.min(high), low.max(high));
{
let mut f = ctl.filter.borrow_mut();
f.min_rating = low as u8;
f.max_rating = (high < 5).then_some(high as u8);
// As for the chips: stars and "nothing judged" cannot
// both hold once a star is asked for.
if low > 0 {
f.unjudged = false;
}
}
let lib = w.global::<Library>();
lib.set_library_filter_min_rating(low);
lib.set_library_filter_max_rating(high);
lib.set_library_filter_unjudged(ctl.filter.borrow().unjudged);
refilter(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
@@ -63,12 +99,14 @@ pub(super) fn wire_filter_ratings_and_people(window: &AppWindow, ctl: &Rc<Librar
// See above: the two cannot both hold.
if on {
f.min_rating = 0;
f.max_rating = None;
f.flag = None;
}
}
w.global::<Library>().set_library_filter_unjudged(on);
if on {
w.global::<Library>().set_library_filter_min_rating(0);
w.global::<Library>().set_library_filter_max_rating(5);
w.global::<Library>().set_library_filter_flag(0);
}
refilter(&w, &ctl);
+49 -3
View File
@@ -931,16 +931,23 @@ pub(super) fn wire_ratings_and_flags(
}
// A rating or flag key. Applies to the whole selection, which is what
// makes judging a run of frames one keystroke rather than forty.
// makes judging a run of frames one keystroke rather than forty — unless
// the pointer is over a photograph outside it, which then takes the key
// alone (darktable's rule; see `judged_images`).
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll_for_keys = coll_ctl.clone();
window
.global::<Library>()
.on_library_judged(move |rating, flag| {
.on_library_judged(move |rating, flag, hovered| {
let Some(w) = weak.upgrade() else { return };
let chosen = coll_for_keys.selected();
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);
// Exactly one axis is meant per keystroke; the other arrives as
// -1 so a star press cannot disturb a flag or the reverse.
@@ -953,6 +960,24 @@ pub(super) fn wire_ratings_and_flags(
}
}
/// TRACES: FR-UI-5
/// What a judgement key applies to: the photograph under the pointer when it
/// is not part of the selection, and the selection otherwise.
///
/// Hovering one of the selected frames rates all of them, so the pointer
/// resting on the grid never shrinks a forty-frame judgement to one. With
/// the pointer off the grid, or over a cell the grid has since scrolled away
/// from, it is the selection as it always was.
fn judged_images(
selection: Vec<dr_types::ImageId>,
under_pointer: Option<dr_types::ImageId>,
) -> Vec<dr_types::ImageId> {
match under_pointer {
Some(id) if !selection.contains(&id) => vec![id],
_ => selection,
}
}
/// --- keywords (FR-CAT-5, FR-CAT-6) ------------------------------------
///
/// Three callbacks and no state of their own: the sheet's open/shut is local
@@ -1003,6 +1028,27 @@ pub(super) fn wire_keywords(
mod tests {
use super::*;
// --- what a judgement key reaches (FR-UI-5) ----------------------------
#[test]
fn a_hovered_photograph_outside_the_selection_is_judged_alone() {
let (a, b, c) = (dr_types::ImageId(1), dr_types::ImageId(2), dr_types::ImageId(3));
assert_eq!(judged_images(vec![a, b], Some(c)), vec![c]);
}
#[test]
fn hovering_a_selected_photograph_judges_the_whole_selection() {
let (a, b) = (dr_types::ImageId(1), dr_types::ImageId(2));
assert_eq!(judged_images(vec![a, b], Some(b)), vec![a, b]);
}
#[test]
fn with_nothing_hovered_the_selection_is_judged() {
let (a, b) = (dr_types::ImageId(1), dr_types::ImageId(2));
assert_eq!(judged_images(vec![a, b], None), vec![a, b]);
assert_eq!(judged_images(vec![], None), vec![]);
}
// --- what the status line says about a keyword (FR-CAT-5) -------------
//
// Split out from the callback for the same reason `decide_drop` is: the
+3
View File
@@ -903,6 +903,9 @@ fn push_filter(window: &AppWindow, ctl: &Rc<LibraryController>) {
window
.global::<Library>()
.set_library_filter_min_rating(filter.min_rating as i32);
window
.global::<Library>()
.set_library_filter_max_rating(filter.max_rating.map_or(5, i32::from));
window
.global::<Library>()
.set_library_filter_unjudged(filter.unjudged);
+13
View File
@@ -24,6 +24,7 @@ impl From<&RatingFilter> for StoredFilter {
fn from(f: &RatingFilter) -> Self {
Self {
min_rating: f.min_rating,
max_rating: f.max_rating,
unjudged: f.unjudged,
flag: f.flag,
local_only: f.local_only,
@@ -40,6 +41,7 @@ impl From<&StoredFilter> for RatingFilter {
fn from(s: &StoredFilter) -> Self {
Self {
min_rating: s.min_rating,
max_rating: s.max_rating.map(|m| m.min(5)),
unjudged: s.unjudged,
flag: s.flag,
local_only: s.local_only,
@@ -178,6 +180,7 @@ mod tests {
captured_at: Some(1_554_300_000),
filter: StoredFilter {
min_rating: 3,
max_rating: None,
unjudged: false,
flag: Some(dr_types::FlagState::Pick),
local_only: true,
@@ -300,6 +303,7 @@ mod tests {
// restored place shows a different set than the one that was left.
let original = RatingFilter {
min_rating: 4,
max_rating: Some(4),
unjudged: true,
flag: Some(dr_types::FlagState::Reject),
local_only: true,
@@ -318,6 +322,15 @@ mod tests {
assert!(back.is_unfiltered());
}
#[test]
fn a_record_from_before_star_ranges_has_no_ceiling() {
// TRACES: FR-UI-5
// Another device on an older build writes records without the field.
let old: StoredFilter = serde_json::from_str(r#"{"min_rating":2}"#).unwrap();
let back: RatingFilter = (&old).into();
assert_eq!((back.min_rating, back.max_rating), (2, None));
}
#[test]
fn a_scope_travels_as_a_uuid() {
// The guard against someone reaching for `collections.id`, which the