Drive develop, the grid, the sidebar and People from the keyboard

An audit of every action by view against the keys the handlers bind left
develop without zoom, pan, fit or a way back to the grid, the grid without
select-none, thumbnail size or keywording, People with no key at all, and
the export and copy sheets without Enter. It also found the reverse gap
FR-UI-5 forbids: pick and reject had no route but P, X and U, and the
2026-09-19 amendment's judging in develop had not been built.

Develop: Ctrl+= and Ctrl+Plus zoom in and Ctrl+- out about the middle of the
view, Ctrl+0 fits and Ctrl+1 goes to 1:1, Shift and an arrow pan a magnified
view, G goes back to the grid, Ctrl+Y redoes, and Enter keeps a crop that hid
a mask. 0-5, P, X and U rate and flag the open photograph without moving on,
with stars and Pick/Reject in the top bar as the pointer and touch route.
= and - nudge the control last moved by a hundredth of its travel; the
framing sliders, perspective included, now count as "last moved", so R puts
them back as well. J turns the selected mask part's join chip.

Grid: Ctrl+D and Ctrl+Shift+A clear the selection, = and - resize the
thumbnails, Ctrl+K opens keywording, and Flag in the selection bar gives
pick and reject a pointer and touch route. Sidebar: Enter commits a
collection's name, and Enter or Escape hands the keyboard back to the grid,
where it used to go nowhere until something was clicked. People: Up and Down
walk the rail, F2 puts the name field under the keys, and Escape or Back now
leave the screen the way its back button does instead of doing nothing.
Sheets: Enter does what the export or copy sheet's button does.

The choices follow Lightroom where it has one. No new key steals typing: the
grid's and People's keys live on focus holders that are not ancestors of any
text field, and the sheets' Enter comes after a focused field has had it.
Every binding is tagged beside its handler, and the gate added in the
previous commit holds the two to each other.
This commit is contained in:
2026-09-24 23:42:26 -04:00
parent 9d1e31ffbb
commit d489a34190
15 changed files with 975 additions and 160 deletions
+40
View File
@@ -411,6 +411,46 @@ fn wire_adjustments(window: &AppWindow, w: &DevelopWiring) {
redraw(&w);
});
}
{
// TRACES: FR-DEV-16
// The nudge keys, on the control last moved: a hundredth of its
// travel, which is the step the slider's own increment takes. Read
// off the row on screen rather than the descriptor, because the row
// is what the photographer is looking at — with masks selected it is
// the layer's value, not the global one.
//
// Scalars only. A switch or a choice has no "a little more", and a
// curve's points are not one number.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window
.global::<Adjustments>()
.on_param_nudged(move |op, param, direction| {
use slint::Model as _;
let Some(w) = weak.upgrade() else { return };
let shown = w.global::<Adjustments>().get_rows();
let Some(row) = shown
.iter()
.find(|r| r.op_index == op && r.param_index == param)
else {
return;
};
if row.kind != "scalar" || row.maximum <= row.minimum {
return;
}
let step = (row.maximum - row.minimum) / 100.0;
let value =
(row.value + direction.signum() as f32 * step).clamp(row.minimum, row.maximum);
if let Some(s) = session.borrow_mut().as_mut() {
s.set_param(op, param, value);
s.rebake_film_if_affected(op);
}
sync_rows(&w, &rows, &session);
redraw(&w);
});
}
// TRACES: FR-DEV-3
// The local-adjustment panel. Wired as a block rather than inline because
// it is a dozen callbacks that all say the same three things, and they
+82 -10
View File
@@ -37,7 +37,7 @@ pub const GESTURES: &[Gesture] = &[
section: "Develop",
touch: "Pinch it with two fingers",
pointer: "The scroll wheel over it",
keys: "",
keys: "Ctrl+= or Ctrl+Plus in, Ctrl+- out, about the middle of the view",
manual: "looking-closer",
},
Gesture {
@@ -45,7 +45,7 @@ pub const GESTURES: &[Gesture] = &[
section: "Develop",
touch: "Drag it",
pointer: "Drag it",
keys: "",
keys: "Shift+←, Shift+→, Shift+↑ and Shift+↓, a fifth of the view at a time",
manual: "looking-closer",
},
Gesture {
@@ -69,7 +69,7 @@ pub const GESTURES: &[Gesture] = &[
section: "Develop",
touch: "Tap the step below the current one in the History list",
pointer: "Click it, or press Redo in the History header",
keys: "Ctrl+Shift+Z",
keys: "Ctrl+Shift+Z, or Ctrl+Y",
manual: "history-snapshots-presets",
},
Gesture {
@@ -120,6 +120,30 @@ pub const GESTURES: &[Gesture] = &[
keys: "Ctrl+E",
manual: "export",
},
Gesture {
title: "Keep a crop that leaves a mask outside",
section: "Develop",
touch: "Press \"Keep crop\" on the notice, or \"Undo crop\" to take it back",
pointer: "Press \"Keep crop\" on the notice, or \"Undo crop\" to take it back",
keys: "Enter keeps it; Ctrl+Z takes the crop back, like any other step",
manual: "",
},
Gesture {
title: "Go back to the grid",
section: "Develop",
touch: "Press \"‹ Library\" in the top bar",
pointer: "Press \"‹ Library\" in the top bar",
keys: "G",
manual: "",
},
Gesture {
title: "Nudge the control last moved",
section: "Develop",
touch: "Drag its track",
pointer: "Drag its track",
keys: "= or Plus up and - down, a hundredth of its travel at a time; hold for more",
manual: "",
},
Gesture {
title: "Change which group of adjustments is on screen",
section: "Develop",
@@ -133,9 +157,25 @@ pub const GESTURES: &[Gesture] = &[
section: "Develop",
touch: "Double-tap the photograph",
pointer: "Double-click it, or press the zoom readout floating over the canvas",
keys: "Z",
keys: "Z goes in and back out; Ctrl+1 goes to 1:1 and Ctrl+0 back to the whole frame",
manual: "looking-closer",
},
Gesture {
title: "Rate this photograph",
section: "Develop",
touch: "Tap a star in the top bar",
pointer: "Click a star in the top bar",
keys: "0–5",
manual: "",
},
Gesture {
title: "Pick or reject this photograph",
section: "Develop",
touch: "Press Pick or Reject in the top bar; again to take the flag off",
pointer: "Press Pick or Reject in the top bar; again to take the flag off",
keys: "P picks, X rejects and U takes the flag off",
manual: "",
},
Gesture {
title: "Give this photograph a colour label",
section: "Develop",
@@ -205,7 +245,7 @@ pub const GESTURES: &[Gesture] = &[
section: "Develop",
touch: "Tap the + / − / ∩ chip on the part's row",
pointer: "Click the + / − / ∩ chip on the part's row",
keys: "",
keys: "J turns the selected part's chip, while masking",
manual: "local-adjustments",
},
Gesture {
@@ -224,6 +264,14 @@ pub const GESTURES: &[Gesture] = &[
keys: "Escape, or Back where the device has one",
manual: "",
},
Gesture {
title: "Do what a sheet offers",
section: "Everywhere",
touch: "Press its button — Export, or Copy",
pointer: "Press its button — Export, or Copy",
keys: "Enter, on the export and copy sheets",
manual: "",
},
Gesture {
title: "Pick a collection up to rearrange the tree",
section: "Collections sidebar",
@@ -253,7 +301,7 @@ pub const GESTURES: &[Gesture] = &[
section: "Collections sidebar",
touch: "Hold the collection, then \"Rename\"",
pointer: "Double-click its name, or right-click it and choose \"Rename\"",
keys: "Escape while typing abandons the new name",
keys: "Type the name and press Enter; Escape abandons it",
manual: "collections",
},
Gesture {
@@ -272,6 +320,22 @@ pub const GESTURES: &[Gesture] = &[
keys: "",
manual: "people",
},
Gesture {
title: "Move between people",
section: "People",
touch: "Tap a person on the rail",
pointer: "Click a person on the rail",
keys: "↑ and ↓, along the rail",
manual: "",
},
Gesture {
title: "Name a person",
section: "People",
touch: "Tap the name field over their faces, type, and press Enter",
pointer: "Click the name field over their faces, type, and press Enter",
keys: "F2 puts the name field under the keys",
manual: "",
},
Gesture {
title: "See a person's photographs",
section: "People",
@@ -368,6 +432,14 @@ pub const GESTURES: &[Gesture] = &[
keys: "Ctrl+V",
manual: "copying-settings",
},
Gesture {
title: "Keyword the selection",
section: "Library grid",
touch: "Select them, then Keywords in the selection bar",
pointer: "Select them, then Keywords in the selection bar",
keys: "Ctrl+K",
manual: "",
},
Gesture {
title: "Show only photographs with some number of stars",
section: "Library grid",
@@ -387,8 +459,8 @@ pub const GESTURES: &[Gesture] = &[
Gesture {
title: "Pick or reject a photograph",
section: "Library grid",
touch: "",
pointer: "",
touch: "Select them, then Flag in the selection bar and Pick, Reject or No flag",
pointer: "Select them, then Flag in the selection bar and Pick, Reject or No flag",
keys: "P picks, X rejects and U takes the flag off, with the pointer over it or on the selection",
manual: "",
},
@@ -429,7 +501,7 @@ pub const GESTURES: &[Gesture] = &[
section: "Library grid",
touch: "Pinch the grid with two fingers",
pointer: "Ctrl and the scroll wheel",
keys: "",
keys: "= or Plus for larger, - for smaller",
manual: "getting-about",
},
Gesture {
@@ -469,7 +541,7 @@ pub const GESTURES: &[Gesture] = &[
section: "Library grid",
touch: "Press Clear in the selection strip",
pointer: "Press Clear in the selection strip",
keys: "",
keys: "Ctrl+D or Ctrl+Shift+A",
manual: "selecting-several",
},
Gesture {
+53
View File
@@ -798,6 +798,44 @@ fn wire_navigation(
reset_name_field(&w);
});
}
// TRACES: FR-UI-5
// Up and down the rail from the keyboard. The rail's order is the model's
// order, so the neighbour is found there and then picked exactly as a
// click on it would pick it — one path for selecting a person, whichever
// way it was asked for. Stops at either end rather than wrapping: a rail
// of two thousand people that jumped from the last to the first would
// lose the user's place.
{
let weak = window.as_weak();
window.on_identity_person_stepped(move |delta| {
let Some(w) = weak.upgrade() else { return };
let people = w.get_identity_people();
let ids: Vec<i32> = people.iter().map(|p| p.id).collect();
if let Some(id) = stepped_person(&ids, w.get_identity_selected(), delta) {
w.invoke_identity_person_picked(id);
}
});
}
}
/// The person `delta` places along the rail from `selected`, or `None` where
/// that is off either end or the rail is empty. With nobody selected, the
/// first key down the rail takes the first person and up takes the last.
fn stepped_person(ids: &[i32], selected: i32, delta: i32) -> Option<i32> {
let last = ids.len().checked_sub(1)?;
let next = match ids.iter().position(|id| *id == selected) {
Some(at) => {
let to = at as i64 + delta.signum() as i64;
if to < 0 || to > last as i64 {
return None;
}
to as usize
}
None if delta < 0 => last,
None => 0,
};
ids.get(next).copied()
}
/// Renaming a person, and the namesake merge offer a rename can raise.
@@ -1600,6 +1638,21 @@ fn park_cover_timer(timer: Option<slint::Timer>) {
#[cfg(test)]
mod tests {
use super::stepped_person;
/// TRACES: FR-UI-5
#[test]
fn stepping_the_rail_stops_at_either_end() {
let rail = [7, 3, 9];
assert_eq!(stepped_person(&rail, 3, 1), Some(9));
assert_eq!(stepped_person(&rail, 3, -1), Some(7));
assert_eq!(stepped_person(&rail, 9, 1), None);
assert_eq!(stepped_person(&rail, 7, -1), None);
assert_eq!(stepped_person(&rail, -1, 1), Some(7));
assert_eq!(stepped_person(&rail, -1, -1), Some(9));
assert_eq!(stepped_person(&[], -1, 1), None);
}
use super::*;
#[test]
+9 -10
View File
@@ -448,6 +448,7 @@ fn reset_view_state(window: &AppWindow) {
// would land on whichever control happens to share them.
window.global::<Adjustments>().set_touched_op(-1);
window.global::<Adjustments>().set_touched_param(-1);
window.global::<Framing>().set_touched(0);
// TRACES: FR-DSP-7
// Emptied rather than left standing: the previous photograph's histogram
// beside the next one's filename is a confident, precise lie, and the gap
@@ -3433,19 +3434,17 @@ fn apply_layout_class(
/// the activity — the behaviour every application there has, and the reason
/// this answers with a bool rather than swallowing the gesture.
fn back_one_step(w: &AppWindow) -> bool {
// TRACES: FR-UI-5 | FR-UI-4
// The Identity screen has no representation in `NavState`: nothing here
// records which view it replaced, only `identity_ui`'s own
// `came_from_library`, which this function has no reason to reach for
// over one screen. Before this refactor, back queued nothing behind
// Identity opened from the library (show-library stayed true, unread by
// anything) and queued `ToLibrary` behind Identity opened from develop —
// but `ToLibrary` only ever set `show-library`, a property
// `IdentityScreen`'s own `if` never read, so both cases looked the same
// on screen: nothing happened. Swallowing the gesture here keeps that:
// falling into the general case below would instead write `active-view`,
// which `IdentityScreen`'s `if` *does* read, and close the screen as a
// side effect nobody asked for.
// `came_from_library`. So back leaves it the way its own "‹ Library" /
// "‹ Develop" button does, through `identity-close`, which knows where it
// came from — rather than through the general case below, which would
// write `active-view` without that knowledge. It used to swallow the key
// and do nothing, which left Escape and Android's Back dead on a whole
// screen.
if w.get_active_view() == View::Identity {
w.invoke_identity_close();
return true;
}
@@ -986,6 +986,29 @@ pub(super) fn wire_ratings_and_flags(
});
}
// TRACES: FR-UI-5 | FR-CULL-4
// The flag of one photograph by row: develop's Pick and Reject, and its
// P, X and U. The open photograph only, for the reason a star clicked in
// the grid rates only its own cell — develop is working on one frame, and
// a selection left behind in the grid is not what is in front of it.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Library>()
.on_library_cell_flagged(move |row, flag| {
let Some(w) = weak.upgrade() 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_judgement(&w, &ctl, &[id], None, Some(flag_from_code(flag)));
});
}
// Fold a burst up, or open it out (FR-CULL-5). A reload rather than a repaint, because
// it changes what the grid's query returns — see [`crate::bursts::toggle`].
{
+41
View File
@@ -1136,6 +1136,47 @@ fn wire_layers_parts(
redraw(&w);
});
}
{
// TRACES: FR-DEV-16 | FR-DEV-19a
// The keyboard's turn of the join chip: the part the tools point at,
// on the one layer being edited, one step round the chip's own cycle.
// Nothing with several layers selected — a part belongs to one — and
// nothing on the base part, which joins nothing.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window
.global::<Masking>()
.on_selected_part_join_cycled(move || {
let Some(w) = weak.upgrade() else { return };
{
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return };
let [id] = s.active_masks() else { return };
let id = id.clone();
let index = s.active_part();
if index == 0 {
return;
}
let Some(join) = s
.masks()
.get(&id)
.and_then(|l| l.part(index))
.map(|p| p.join)
else {
return;
};
let at = dr_pipeline::mask::Join::ALL
.iter()
.position(|j| *j == join)
.unwrap_or(0);
let next = (at + 1) % dr_pipeline::mask::Join::ALL.len();
s.set_mask_part_join(&id, index, next);
}
sync(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();