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
+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 —