diff --git a/docs/ui-refinement.md b/docs/ui-refinement.md
index 08aa5de..772a46c 100644
--- a/docs/ui-refinement.md
+++ b/docs/ui-refinement.md
@@ -440,6 +440,44 @@ there is hover state but no *selection* state.
**Done when.** The grid reads as images rather than boxes, the current image
is unambiguous, and arrows plus Enter navigate it without the mouse.
+### Keyboard navigation — **done**
+
+Arrows walk the grid, shift+arrow extends the selection, Home/End reach the
+ends, PageUp/PageDown move by a screenful, and `Return` opens what the cursor
+is on. With the judgement keys the grid already bound, a culling pass is now a
+keyboard job end to end — which is the point: a cull is thousands of decisions,
+and reaching for the mouse between each is the difference between an hour and
+an evening.
+
+**The cursor is a library ordinal, not a row of the loaded window** — that is
+the whole design, and it is the same argument the selection already made by
+keying on ids. The window is a few screenfuls around wherever the user is
+looking; a cursor held as a row would stop at the window's edge or, worse, keep
+counting into cells that belong to other photographs. Walking out of the window
+reloads it around the new position, exactly as scrolling does.
+
+The same fault was already live in the **anchor**, which was a window row: a
+shift-click after a scroll extended from whatever image had drifted into that
+row. It is now an ordinal too, and `apply_press` takes the window's offset to
+map between the two. A range longer than the loaded window truncates to what is
+loaded — selection is by id, and an image the catalog has not been asked for
+has no id to select — which is the honest failure, not the silent one.
+
+`select_row` is shared by the pointer and the keyboard so the two cannot drift:
+"click here, shift+down twice" has to mean what "click here, shift-click there"
+means. The one deliberate difference is that a plain arrow collapses the
+selection onto the cursor, where a plain *click* on an already-selected cell
+leaves it alone — that exception exists so a multi-image drag can start from
+one of its members, and there is no drag behind a keystroke.
+
+**Not done here:** a cursor marker distinct from the selection ring. A plain
+arrow selects what it lands on, so the ring shows it; only during a shift
+extension is the moving end indistinguishable from the rest of the range.
+That wants a `cursor` flag on `LibraryCell` and a second ring treatment.
+
+**Still open in D:** cells cropping to fill, the cell background receding to
+the ground, and hover reading distinctly from selection.
+
---
## Workstream E — Chrome hierarchy
diff --git a/ui/dr-ui/src/collections_ui.rs b/ui/dr-ui/src/collections_ui.rs
index e6dd064..75a4319 100644
--- a/ui/dr-ui/src/collections_ui.rs
+++ b/ui/dr-ui/src/collections_ui.rs
@@ -59,7 +59,22 @@ pub struct CollectionsController {
/// Where a shift-click extends from. The last cell *clicked*, not the last
/// added — extending from the far end of a previous range is not what the
/// gesture means anywhere else.
+ ///
+ /// An **image ordinal in the library**, not a row of the loaded window.
+ /// The grid is a window over the catalog, so row 7 names a different
+ /// photograph after every scroll: held as a row, an anchor set before a
+ /// scroll described a range from wherever that row had since drifted to.
+ /// Selection is by id for the same reason (see the preamble); this is the
+ /// same argument applied to the one index that has to survive a move.
anchor: RefCell>,
+ /// Where the keyboard is, as an image ordinal.
+ ///
+ /// Distinct from the anchor, and it has to be: shift+arrow grows a range
+ /// *from* the anchor *to* the cursor, so the two are the two ends and
+ /// cannot be one field. `None` until the user has taken hold of the grid,
+ /// so the first arrow press starts from what is on screen rather than
+ /// jumping to the top of the library.
+ cursor: std::cell::Cell >,
/// Whether the press that began the current gesture carried ctrl or shift.
///
/// A modified click is a *selection* gesture and must not also open the
@@ -166,6 +181,16 @@ impl CollectionsController {
pub fn clear_selection(&self) {
self.selection.borrow_mut().clear();
*self.anchor.borrow_mut() = None;
+ self.cursor.set(None);
+ }
+
+ /// Where the keyboard cursor is, as an image ordinal.
+ pub fn cursor(&self) -> Option {
+ self.cursor.get()
+ }
+
+ pub fn set_cursor(&self, at: Option) {
+ self.cursor.set(at);
}
}
@@ -185,15 +210,21 @@ impl CollectionsController {
/// A plain press on an image that is *already* selected leaves the selection
/// alone. That is what makes dragging a multi-selection possible at all — the
/// press that begins the drag would otherwise collapse the selection to one.
+///
+/// `ids` is the **loaded window**, `offset` where it starts in the library and
+/// `row` a position within it; the anchor is kept as `offset + row`, an
+/// ordinal that still names the same photograph after the window has moved.
pub fn apply_press(
selection: &mut BTreeSet,
anchor: &mut Option,
ids: &[ImageId],
+ offset: usize,
row: usize,
ctrl: bool,
shift: bool,
) {
let Some(&id) = ids.get(row) else { return };
+ let here = offset + row;
if shift {
let Some(from) = *anchor else {
@@ -201,7 +232,7 @@ pub fn apply_press(
// the anchor, so the *next* shift-click has a range to describe.
selection.clear();
selection.insert(id);
- *anchor = Some(row);
+ *anchor = Some(here);
return;
};
@@ -219,13 +250,26 @@ pub fn apply_press(
selection.clear();
}
- let (lo, hi) = if from <= row {
- (from, row)
+ let (lo, hi) = if from <= here {
+ (from, here)
} else {
- (row, from)
+ (here, from)
};
- let hi = hi.min(ids.len().saturating_sub(1));
- for id in &ids[lo..=hi] {
+
+ // The range is described in ordinals and applied to the window: only
+ // ids that are loaded can be selected, because selection is by id and
+ // an image the catalog has not been asked for has no id here. A range
+ // longer than the loaded window is therefore truncated to it rather
+ // than silently selecting the wrong photographs — which is what
+ // holding the anchor as a window row used to do.
+ //
+ // The slice is always in range and needs no guard: `ids.get(row)`
+ // succeeded, so the window is non-empty and `here` is inside it, and
+ // `here` is one of the two bounds.
+ let last = ids.len() - 1;
+ let lo_row = lo.saturating_sub(offset);
+ let hi_row = hi.saturating_sub(offset).min(last);
+ for id in &ids[lo_row..=hi_row] {
selection.insert(*id);
}
return;
@@ -235,7 +279,7 @@ pub fn apply_press(
if !selection.remove(&id) {
selection.insert(id);
}
- *anchor = Some(row);
+ *anchor = Some(here);
return;
}
@@ -243,13 +287,45 @@ pub fn apply_press(
// follow carries the whole selection, and collapsing it here would make a
// multi-image drag impossible to start.
if selection.contains(&id) {
- *anchor = Some(row);
+ *anchor = Some(here);
return;
}
selection.clear();
selection.insert(id);
- *anchor = Some(row);
+ *anchor = Some(here);
+}
+
+/// Apply a press and push the result into the grid — the whole of what a
+/// click, or an arrow key, does to the selection.
+///
+/// Shared so the keyboard and the pointer cannot drift: they are the same
+/// gesture reached two ways, and the moment one of them grew its own copy of
+/// the selection policy, "click here, shift+down twice" would stop meaning
+/// what "click here, shift-click there" means.
+pub fn select_row(
+ window: &AppWindow,
+ ctl: &Rc,
+ ids: &[ImageId],
+ offset: usize,
+ row: usize,
+ ctrl: bool,
+ shift: bool,
+) {
+ apply_press(
+ &mut ctl.selection.borrow_mut(),
+ &mut ctl.anchor.borrow_mut(),
+ ids,
+ offset,
+ row,
+ ctrl,
+ shift,
+ );
+ // The cursor follows the press, so an arrow key after a click continues
+ // from the cell that was clicked rather than from wherever the keyboard
+ // was last.
+ ctl.set_cursor(Some(offset + row));
+ sync_selection(window, ctl, ids);
}
/// Rebuild the sidebar from the catalog.
@@ -973,15 +1049,11 @@ pub fn wire(
// a selection and must not also navigate to develop.
ctl.modified_press.set(ctrl_held || shift_held);
- apply_press(
- &mut ctl.selection.borrow_mut(),
- &mut ctl.anchor.borrow_mut(),
- &ids,
- row as usize,
- ctrl_held,
- shift_held,
- );
- sync_selection(&w, &ctl, &ids);
+ // Where the window starts, so the press is recorded as the ordinal
+ // it is rather than as a row that stops meaning this photograph on
+ // the next scroll.
+ let offset = w.get_library_offset().max(0) as usize;
+ select_row(&w, &ctl, &ids, offset, row as usize, ctrl_held, shift_held);
});
}
@@ -1753,8 +1825,8 @@ mod tests {
let mut sel = BTreeSet::new();
let mut anchor = None;
- apply_press(&mut sel, &mut anchor, &all, 0, false, false);
- apply_press(&mut sel, &mut anchor, &all, 2, false, false);
+ apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false);
+ apply_press(&mut sel, &mut anchor, &all, 0, 2, false, false);
assert_eq!(sel.iter().copied().collect::>(), vec![ImageId(3)]);
}
@@ -1765,12 +1837,12 @@ mod tests {
let mut sel = BTreeSet::new();
let mut anchor = None;
- apply_press(&mut sel, &mut anchor, &all, 0, false, false);
- apply_press(&mut sel, &mut anchor, &all, 3, true, false);
+ apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false);
+ apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false);
assert_eq!(sel.len(), 2);
// Toggling: a second ctrl-press on the same cell takes it out again.
- apply_press(&mut sel, &mut anchor, &all, 3, true, false);
+ apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false);
assert_eq!(sel.iter().copied().collect::>(), vec![ImageId(1)]);
}
@@ -1780,8 +1852,8 @@ mod tests {
let mut sel = BTreeSet::new();
let mut anchor = None;
- apply_press(&mut sel, &mut anchor, &all, 2, false, false);
- apply_press(&mut sel, &mut anchor, &all, 6, false, true);
+ apply_press(&mut sel, &mut anchor, &all, 0, 2, false, false);
+ apply_press(&mut sel, &mut anchor, &all, 0, 6, false, true);
assert_eq!(sel.len(), 5, "rows 2..=6 inclusive");
assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(7)));
@@ -1793,8 +1865,8 @@ mod tests {
let mut sel = BTreeSet::new();
let mut anchor = None;
- apply_press(&mut sel, &mut anchor, &all, 6, false, false);
- apply_press(&mut sel, &mut anchor, &all, 2, false, true);
+ apply_press(&mut sel, &mut anchor, &all, 0, 6, false, false);
+ apply_press(&mut sel, &mut anchor, &all, 0, 2, false, true);
assert_eq!(sel.len(), 5);
}
@@ -1807,12 +1879,12 @@ mod tests {
let mut sel = BTreeSet::new();
let mut anchor = None;
- apply_press(&mut sel, &mut anchor, &all, 5, false, false);
- apply_press(&mut sel, &mut anchor, &all, 15, false, true);
+ apply_press(&mut sel, &mut anchor, &all, 0, 5, false, false);
+ apply_press(&mut sel, &mut anchor, &all, 0, 15, false, true);
assert_eq!(sel.len(), 11, "rows 5..=15");
// Corrected to a shorter range from the same anchor.
- apply_press(&mut sel, &mut anchor, &all, 8, false, true);
+ apply_press(&mut sel, &mut anchor, &all, 0, 8, false, true);
assert_eq!(sel.len(), 4, "rows 5..=8, and nothing from the first range");
assert!(!sel.contains(&ImageId(16)), "row 15 is no longer selected");
}
@@ -1825,9 +1897,9 @@ mod tests {
let mut sel = BTreeSet::new();
let mut anchor = None;
- apply_press(&mut sel, &mut anchor, &all, 10, false, false);
- apply_press(&mut sel, &mut anchor, &all, 14, false, true);
- apply_press(&mut sel, &mut anchor, &all, 12, false, true);
+ apply_press(&mut sel, &mut anchor, &all, 0, 10, false, false);
+ apply_press(&mut sel, &mut anchor, &all, 0, 14, false, true);
+ apply_press(&mut sel, &mut anchor, &all, 0, 12, false, true);
assert_eq!(anchor, Some(10));
assert_eq!(sel.len(), 3, "rows 10..=12");
@@ -1841,13 +1913,13 @@ mod tests {
let mut sel = BTreeSet::new();
let mut anchor = None;
- apply_press(&mut sel, &mut anchor, &all, 0, false, false);
- apply_press(&mut sel, &mut anchor, &all, 2, false, true);
+ apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false);
+ apply_press(&mut sel, &mut anchor, &all, 0, 2, false, true);
assert_eq!(sel.len(), 3);
// A new anchor by ctrl-click, then a ctrl+shift range from it.
- apply_press(&mut sel, &mut anchor, &all, 10, true, false);
- apply_press(&mut sel, &mut anchor, &all, 12, true, true);
+ apply_press(&mut sel, &mut anchor, &all, 0, 10, true, false);
+ apply_press(&mut sel, &mut anchor, &all, 0, 12, true, true);
assert_eq!(sel.len(), 6, "rows 0..=2 and 10..=12");
assert!(sel.contains(&ImageId(1)) && sel.contains(&ImageId(13)));
@@ -1861,13 +1933,13 @@ mod tests {
let mut sel = BTreeSet::new();
let mut anchor = None;
- apply_press(&mut sel, &mut anchor, &all, 0, false, false);
- apply_press(&mut sel, &mut anchor, &all, 1, true, false);
- apply_press(&mut sel, &mut anchor, &all, 2, true, false);
+ apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false);
+ apply_press(&mut sel, &mut anchor, &all, 0, 1, true, false);
+ apply_press(&mut sel, &mut anchor, &all, 0, 2, true, false);
assert_eq!(sel.len(), 3);
// Pressing one of the three to begin a drag.
- apply_press(&mut sel, &mut anchor, &all, 1, false, false);
+ apply_press(&mut sel, &mut anchor, &all, 0, 1, false, false);
assert_eq!(sel.len(), 3, "the selection survived the press");
}
@@ -1878,17 +1950,84 @@ mod tests {
let mut sel = BTreeSet::new();
let mut anchor = None;
- apply_press(&mut sel, &mut anchor, &all, 99, false, false);
+ apply_press(&mut sel, &mut anchor, &all, 0, 99, false, false);
assert!(sel.is_empty());
}
+ #[test]
+ fn an_anchor_survives_the_window_moving_under_it() {
+ // The grid is a window over the catalog, and the window moves whenever
+ // the user scrolls. Held as a row, an anchor set before a scroll
+ // described a range from whatever photograph had since drifted into
+ // that row — so shift-clicking after scrolling selected a run the user
+ // never pointed at, silently and with no way to tell.
+ let all = ids(20);
+ let first: Vec<_> = all[0..10].to_vec();
+ let later: Vec<_> = all[4..14].to_vec();
+
+ let mut sel = BTreeSet::new();
+ let mut anchor = None;
+
+ // Anchor on the sixth image, in a window starting at the beginning.
+ apply_press(&mut sel, &mut anchor, &first, 0, 5, false, false);
+ assert_eq!(anchor, Some(5), "the anchor is an ordinal, not a row");
+
+ // The user scrolls — the window now starts four images in — and
+ // shift-clicks the image at ordinal 10.
+ apply_press(&mut sel, &mut anchor, &later, 4, 6, false, true);
+
+ assert_eq!(sel.len(), 6, "ordinals 5..=10");
+ assert!(sel.contains(&ImageId(6)), "the anchored image is still in");
+ assert!(sel.contains(&ImageId(11)), "up to the one shift-clicked");
+ assert!(!sel.contains(&ImageId(5)), "and nothing before the anchor");
+ }
+
+ #[test]
+ fn a_range_reaching_outside_the_window_selects_what_is_loaded() {
+ // Selection is by id, so a range can only cover images the window
+ // holds. Truncating is the honest answer; the alternative — indexing
+ // from the window's start as though it were the library's — selects
+ // the wrong photographs and looks like it worked.
+ let all = ids(30);
+ let window: Vec<_> = all[10..20].to_vec();
+
+ let mut sel = BTreeSet::new();
+ let mut anchor = Some(0);
+
+ apply_press(&mut sel, &mut anchor, &window, 10, 3, false, true);
+
+ assert_eq!(sel.len(), 4, "ordinals 10..=13, the loaded part of 0..=13");
+ assert!(sel.contains(&ImageId(11)) && sel.contains(&ImageId(14)));
+ }
+
+ #[test]
+ fn a_range_running_off_the_far_end_stops_at_the_window() {
+ // The mirror of the case above, with the anchor *ahead* of the press
+ // instead of behind it. Both directions truncate to what is loaded,
+ // and neither may wrap round to the other end of the window.
+ let all = ids(30);
+ let window: Vec<_> = all[0..5].to_vec();
+
+ let mut sel = BTreeSet::new();
+ let mut anchor = Some(25);
+
+ apply_press(&mut sel, &mut anchor, &window, 0, 2, false, true);
+
+ assert_eq!(sel.len(), 3, "ordinals 2..=4, the loaded part of 2..=25");
+ assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(5)));
+ assert!(
+ !sel.contains(&ImageId(1)),
+ "nothing before the pressed cell"
+ );
+ }
+
#[test]
fn shift_without_an_anchor_selects_just_the_one() {
let all = ids(5);
let mut sel = BTreeSet::new();
let mut anchor = None;
- apply_press(&mut sel, &mut anchor, &all, 3, false, true);
+ apply_press(&mut sel, &mut anchor, &all, 0, 3, false, true);
assert_eq!(sel.iter().copied().collect::>(), vec![ImageId(4)]);
}
diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs
index d8b72d8..f1addc6 100644
--- a/ui/dr-ui/src/library_ui.rs
+++ b/ui/dr-ui/src/library_ui.rs
@@ -2252,6 +2252,101 @@ fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image {
slint::Image::from_rgba8(buf)
}
+/// Walk the keyboard cursor through the library — the arrow keys.
+///
+/// **The cursor is a library ordinal, not a row of the loaded window.** That is
+/// what lets it walk past the window's edge: the window is a few screenfuls
+/// around wherever the user is looking, and a cursor held as a row would stop
+/// at its end or, worse, keep counting into cells belonging to different
+/// photographs. Moving out of the window reloads it around the new position,
+/// which is the same thing scrolling does.
+///
+/// The grid supplies the step, because how far "down" is depends on how many
+/// columns the window happens to be showing, and only the grid knows that. It
+/// does not clamp: `Home` and `End` arrive as a step longer than the library
+/// and are clamped here, where the total is known.
+fn move_cursor(
+ window: &AppWindow,
+ ctl: &Rc,
+ coll: &Rc,
+ delta: i32,
+ extend: bool,
+) {
+ let total = window.get_library_total().max(0) as usize;
+ if total == 0 {
+ return;
+ }
+
+ let Some(from) = coll.cursor() else {
+ // The first press takes hold of the grid rather than moving in it. A
+ // key that jumped to image zero would lose wherever the user had
+ // scrolled to, and one that started from a cell off the top of the
+ // screen would appear to do nothing but scroll.
+ //
+ // The first *visible* ordinal, not the window's start: the loaded
+ // window deliberately begins a quarter of a screen above the view, so
+ // its first cell is one the user cannot see.
+ let at = ctl.resume_at.get().min(total - 1);
+ place_cursor(window, ctl, coll, at, false);
+ return;
+ };
+
+ // Saturating in `isize`, so a `Home` expressed as minus the library's
+ // length does not wrap round to the end.
+ let next = (from as isize)
+ .saturating_add(delta as isize)
+ .clamp(0, total as isize - 1) as usize;
+ if next == from {
+ // Already at the end being pressed toward. Nothing to move, and
+ // reloading the window would be a visible jerk for no movement.
+ return;
+ }
+ place_cursor(window, ctl, coll, next, extend);
+}
+
+/// Put the cursor on one image, bringing the window with it.
+fn place_cursor(
+ window: &AppWindow,
+ ctl: &Rc,
+ coll: &Rc,
+ at: usize,
+ extend: bool,
+) {
+ // Bring the window to the cursor if it has walked out of it. Centred a
+ // quarter in, exactly as a scroll does it, so continuing in the same
+ // direction has loaded cells to move into rather than another reload on
+ // the very next press.
+ let loaded = window.get_library_cells().row_count();
+ let offset = *ctl.offset.borrow();
+ if at < offset || at >= offset + loaded {
+ let size = *ctl.window.borrow();
+ *ctl.offset.borrow_mut() = at.saturating_sub(size / 4);
+ load_window(window, ctl);
+ }
+
+ // Re-read: `load_window` clamps the offset against the library's end, so
+ // the window may not start where it was asked to.
+ let offset = *ctl.offset.borrow();
+ let ids = ctl.visible_ids();
+ let Some(row) = at.checked_sub(offset).filter(|r| *r < ids.len()) else {
+ // The window could not be brought to the cursor — an empty or
+ // shrinking library. Leaving the cursor where it was is better than
+ // pointing it at nothing.
+ return;
+ };
+
+ if !extend {
+ // An arrow key collapses the selection onto the cursor. A *click* on
+ // an already-selected cell deliberately leaves the selection alone —
+ // that exception exists so a multi-image drag can start from one of
+ // its members — and there is no drag behind a keystroke.
+ coll.clear_selection();
+ }
+
+ crate::collections_ui::select_row(window, coll, &ids, offset, row, false, extend);
+ window.set_library_cursor(at as i32);
+}
+
/// Connect the grid's callbacks.
pub fn wire(
window: &AppWindow,
@@ -2261,10 +2356,15 @@ pub fn wire(
) where
F: Fn(String) + 'static,
{
+ // Shared rather than moved: a click and `Return` both open an image, and
+ // they are two callbacks.
+ let on_open_image = Rc::new(on_open_image);
+
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll_for_click = coll_ctl.clone();
+ let on_open_image = on_open_image.clone();
window.on_library_cell_clicked(move |i| {
// A ctrl- or shift-click is a selection gesture. Opening the image
// too would throw the user out of the grid mid-selection.
@@ -2283,6 +2383,47 @@ pub fn wire(
});
}
+ // --- the keyboard cursor (FR-CULL-4) -----------------------------------
+ //
+ // Walking the grid with the arrows, and opening with `Return`. Together
+ // with the judgement keys already bound in the grid, this is what makes a
+ // culling pass a keyboard job: move, rate, move, open the doubtful one,
+ // come back. A cull is thousands of decisions, and reaching for the mouse
+ // between each of them is the difference between an hour and an evening.
+ {
+ let weak = window.as_weak();
+ let ctl = ctl.clone();
+ let coll = coll_ctl.clone();
+ window.on_library_move_cursor(move |delta, extend| {
+ let Some(w) = weak.upgrade() else { return };
+ move_cursor(&w, &ctl, &coll, delta, extend);
+ });
+ }
+
+ {
+ let weak = window.as_weak();
+ let ctl = ctl.clone();
+ let coll = coll_ctl.clone();
+ let on_open = on_open_image.clone();
+ window.on_library_open_cursor(move || {
+ let Some(w) = weak.upgrade() else { return };
+ // The cursor is a library ordinal and `paths` is the loaded
+ // window, so the row is the difference. A cursor outside the
+ // window cannot happen — moving it loads the window around it —
+ // but a shrinking library could leave one behind, and opening the
+ // wrong photograph is worse than opening none.
+ let Some(cursor) = coll.cursor() else { return };
+ let offset = *ctl.offset.borrow();
+ let path = cursor
+ .checked_sub(offset)
+ .and_then(|row| ctl.paths.borrow().get(row).cloned());
+ if let Some(path) = path {
+ w.set_show_library(false);
+ on_open(path);
+ }
+ });
+ }
+
// Ctrl+wheel or pinch over the grid resizes the cells.
//
// Geometric steps rather than fixed pixels: the same gesture should feel
diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint
index e8e89bf..21e75be 100644
--- a/ui/dr-ui/ui/app.slint
+++ b/ui/dr-ui/ui/app.slint
@@ -415,6 +415,13 @@ export component AppWindow inherits Window {
callback library-cell-pressed(int, bool, bool);
callback library-remove-from-collection();
+ // The keyboard cursor: where the arrows are in the library, as an image
+ // ordinal. Rust owns it — clamping it needs the library's length, and
+ // moving it may have to swap the loaded window underneath.
+ in property library-cursor: -1;
+ callback library-move-cursor(int, bool);
+ callback library-open-cursor();
+
// --- ratings and flags (FR-CAT-5, FR-CULL-4) ---
//
// Stars and pick/reject, set from the grid and persisted to the catalog
@@ -825,6 +832,11 @@ export component AppWindow inherits Window {
cell-pressed(i, ctrl, shift) => {
root.library-cell-pressed(i, ctrl, shift);
}
+ cursor: root.library-cursor;
+ move-cursor(delta, extend) => {
+ root.library-move-cursor(delta, extend);
+ }
+ open-cursor() => { root.library-open-cursor(); }
drag-image: root.library-drag-image;
drag-payload() => { return root.library-drag-payload(); }
drag-started(i) => { root.library-drag-started(i); }
diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint
index 186d42f..5e342a0 100644
--- a/ui/dr-ui/ui/library.slint
+++ b/ui/dr-ui/ui/library.slint
@@ -672,6 +672,29 @@ export component LibraryGrid inherits Rectangle {
/// How many images are selected, for the header's count.
in property selected-count: 0;
+
+ // --- the keyboard cursor ------------------------------------------------
+ //
+ // Where the keyboard is in the library, as an **image ordinal** — not a
+ // row of the loaded window, which names a different photograph after every
+ // scroll. Rust owns it, because clamping it needs the library's length and
+ // moving it may have to swap the window underneath.
+ //
+ // -1 before the user has taken hold of it, so a fresh grid draws no cursor
+ // and the first arrow press picks up where the view already is rather than
+ // teleporting to image zero.
+ in property cursor: -1;
+ /// Move the cursor by a number of images; the flag extends the selection
+ /// from the anchor instead of replacing it.
+ ///
+ /// A signed step and nothing else. Home and End are this with a step
+ /// longer than the library, which Rust clamps — so this file needs to know
+ /// neither how many images there are nor where the loaded window starts.
+ callback move-cursor(int, bool);
+ /// Open the image under the cursor. `Return`, and the reason the arrows
+ /// are worth having: a cull is walk, judge, open, back, without the hand
+ /// ever leaving the keyboard.
+ callback open-cursor();
/// Which collection scopes the grid, for the header. Empty means all.
in property scope-label: "";
/// Take the selection out of the collection currently being shown. Only
@@ -1165,6 +1188,65 @@ export component LibraryGrid inherits Rectangle {
root.rename-scope();
return accept;
}
+
+ // --- walking the grid ---------------------------------
+ //
+ // The keys that make a cull possible without the mouse:
+ // arrows move the cursor, shift extends the selection from
+ // the anchor, `Return` opens what the cursor is on. The
+ // judgement keys above act on the selection, so walking
+ // with the arrows and rating as you go is one hand's work.
+ //
+ // Every one of these is `accept`ed. The `Flickable` scrolls
+ // on arrow keys of its own accord, and letting it would
+ // move the view out from under a cursor that had not
+ // moved — the grid is scrolled *to* the cursor instead,
+ // and only when the cursor leaves the viewport.
+ //
+ // The vertical steps are expressed in columns and rows,
+ // which only the grid knows: how far "down" is depends on
+ // how wide the window happens to be.
+ if (event.text == Key.LeftArrow) {
+ root.move-cursor(-1, event.modifiers.shift);
+ return accept;
+ }
+ if (event.text == Key.RightArrow) {
+ root.move-cursor(1, event.modifiers.shift);
+ return accept;
+ }
+ if (event.text == Key.UpArrow) {
+ root.move-cursor(-root.columns, event.modifiers.shift);
+ return accept;
+ }
+ if (event.text == Key.DownArrow) {
+ root.move-cursor(root.columns, event.modifiers.shift);
+ return accept;
+ }
+ if (event.text == Key.PageUp) {
+ root.move-cursor(-root.columns * root.visible-rows,
+ event.modifiers.shift);
+ return accept;
+ }
+ if (event.text == Key.PageDown) {
+ root.move-cursor(root.columns * root.visible-rows,
+ event.modifiers.shift);
+ return accept;
+ }
+ // A step longer than the library, clamped at the far end.
+ // `total` is what the grid was told the library holds, so
+ // this stays honest as it grows.
+ if (event.text == Key.Home) {
+ root.move-cursor(-root.total, event.modifiers.shift);
+ return accept;
+ }
+ if (event.text == Key.End) {
+ root.move-cursor(root.total, event.modifiers.shift);
+ return accept;
+ }
+ if (event.text == Key.Return) {
+ root.open-cursor();
+ return accept;
+ }
return reject;
}
}
@@ -1235,6 +1317,34 @@ export component LibraryGrid inherits Rectangle {
property token: root.scroll-token;
changed token => { self.seek(); }
+ // Keep the keyboard cursor in view, moving as little as will
+ // do it.
+ //
+ // Deliberately *not* `seek()`: that puts the requested row at
+ // the top, which is right for a scrub — the user asked to go
+ // to a date and expects to arrive there — and wrong for an
+ // arrow key, where the grid jumping a row upward on every
+ // press makes the row impossible to read. So a cursor already
+ // on screen moves nothing at all, and one that has just left
+ // brings in exactly its own row.
+ property pitch: root.cell-size + Theme.gap;
+ property cursor-row: floor(root.cursor / max(1, root.columns));
+ changed cursor-row => { self.reveal(); }
+
+ function reveal() {
+ if (root.cursor < 0) {
+ return;
+ }
+ let top = Theme.gap + self.cursor-row * self.pitch;
+ let shown = -self.viewport-y;
+ let bottom = max(0px, self.viewport-height - self.height);
+ if (top < shown) {
+ self.viewport-y = -min(bottom, top);
+ } else if (top + self.pitch > shown + self.height) {
+ self.viewport-y = -min(bottom, top + self.pitch - self.height);
+ }
+ }
+
// Also on creation, which is what returning from the develop
// view needs. `show-library` gates an `if`, so the grid is built
// anew and `token` is *initialised* to the already-bumped value