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
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:
@@ -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",
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user