Make the thin ring the only mark a selected photograph carries

Two treatments said "selected" and neither said it well.

A selected cell got a 2px border and a lifted fill. The border is drawn
on the outside of a cell whose content sits 6px in, so it ate into the
thumbnail: selecting appeared to nudge the photograph. Inside it, a
second thin ring marked the anchor — the end a shift-click measures from
— and that ring was the clearest thing on the cell, so it read as *the*
selection to everyone who had not written it.

Worse, the ring outlived what it described. An anchor survives a
deselection, so a thin box sat around the last photograph touched with
nothing selected at all, indistinguishable from a cell that had stayed
behind. That is the one thing a selection cue must never be: ambiguous
about whether something is selected.

So the ring is now what it already looked like. One mark, drawn inside
the cell and 4px clear of its edge, so it never touches the thumbnail and
never changes a dimension — selecting adds ink and moves nothing. Two
pixels rather than one, because it is now carrying the whole cue across
forty cells at arm's length against a thumbnail of any brightness. The
outer border is hover alone.

The anchor keeps no mark, and loses nothing it was earning: the bar says
"Tap the last photograph" while a range is armed, which answers the
question the ring existed to answer. The ordinal still lives in the
controller and still decides where a range extends from; what is gone is
the claim that the user needs to see it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 09:47:32 +02:00
co-authored by Claude Opus 5
parent b120ce48ec
commit 9e519eb8a6
3 changed files with 50 additions and 28 deletions
+14 -8
View File
@@ -898,19 +898,25 @@ pub fn sync_lifted(window: &AppWindow, lifted: &[ImageId], ids: &[ImageId]) {
pub fn sync_selection(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[ImageId]) {
let selection = ctl.selection.borrow();
let model = window.get_library_cells();
// The anchor is an ordinal and the model is a window, so it marks a row
// only while the photograph it names is on screen. Off screen it marks
// nothing, which is the honest answer: the range still extends from there.
let offset = window.get_library_offset().max(0) as usize;
let anchor_row = ctl.anchor.borrow().and_then(|at| at.checked_sub(offset));
// The anchor is deliberately **not** pushed to the grid.
//
// It used to be, and it was drawn as a thin ring inside the cell. Two
// things were wrong with that. It was the clearest mark on the cell, so it
// read as *the* selection to anyone who had not written it; and an anchor
// outlives a deselection, so the ring sat around the last photograph
// touched while nothing was selected at all — indistinguishable from a cell
// that had stayed behind.
//
// The ordinal still lives in the controller and still decides where a range
// extends from. What is gone is the claim that the user needs to see it:
// the selection bar says "Tap the last photograph" while a range is armed,
// which answers the question the ring was there to answer.
for (row, id) in ids.iter().enumerate() {
let want = selection.contains(id);
let anchored = anchor_row == Some(row);
if let Some(mut cell) = model.row_data(row) {
if cell.selected != want || cell.anchor != anchored {
if cell.selected != want {
cell.selected = want;
cell.anchor = anchored;
model.set_row_data(row, cell);
}
}
-1
View File
@@ -2305,7 +2305,6 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
// which owns the selection and queries the badge counts for the
// whole window in one statement rather than one per cell.
selected: false,
anchor: false,
collection_count: 0,
// Likewise filled by `sync_ratings` below — one query for the
// window, not one per cell.
+36 -19
View File
@@ -447,14 +447,6 @@ export struct LibraryCell {
// Part of the current selection. Selection is what a drag carries, so this
// has to be per-cell state rather than a single "current" index.
selected: bool,
// The cell a shift-click would extend *from* — the last one plainly
// clicked. Marked because otherwise the gesture has a hidden operand: the
// user picks the far end of a run with no way to see which end the range
// is being measured from, and a range that comes out wrong gives no clue
// why. At most one cell in the library carries this, and often none of
// them is on screen — which is itself the answer to "why did that select
// so much".
anchor: bool,
// How many collections this image belongs to. An image can be in many at
// once, and without a cue the grid gives no hint that a photograph has
// already been filed — the user re-files it, or hunts for where it went.
@@ -2973,12 +2965,18 @@ export component LibraryGrid inherits Rectangle {
height: parent.height - 2 * self.y;
animate x, y, width, height { duration: 120ms; easing: ease-out; }
background: cell.selected ? Theme.selected : Theme.surface;
background: Theme.surface;
border-radius: Theme.radius;
// Selection outranks hover: a selected cell must stay legible
// once the pointer has moved on to the collection it is being
// dragged toward.
border-width: cell.selected ? 2px : (cell-touch.has-hover ? 1px : 0px);
// Hover only. **Selection is the inset ring below**, and
// nothing out here changes when a cell is selected.
//
// It used to be a 2px border and a lifted fill, and a
// border drawn on the outside of a 6px-padded cell eats
// into the thumbnail: selecting appeared to nudge the
// photograph, which is exactly the wrong feedback for a
// gesture whose whole job is to say "this one". One
// treatment, drawn inside, in one place.
border-width: cell-touch.has-hover ? 1px : 0px;
border-color: Theme.selected-ring;
clip: true;
@@ -3061,18 +3059,37 @@ export component LibraryGrid inherits Rectangle {
}
}
// The end a shift-click measures from. An inner ring rather
// than a colour of its own: it has to be legible against a
// thumbnail of any brightness and must not read as a second
// kind of selection, which a hue would.
// **Selection, and the only mark of it.**
//
// This ring used to mean something narrower — the end a
// shift-click measures from — and it was the clearest thing
// on the cell, so it read as the selection to everyone who
// had not written it. It also outlived what it described: an
// anchor survives a deselection, so a thin box sat around
// the last photograph touched with nothing selected at all,
// and there was no way to tell it apart from a cell that had
// stayed behind.
//
// So the ring is now what it already looked like. The anchor
// has no mark of its own, which costs nothing it was earning:
// the range gesture announces itself in the bar — "Tap the
// last photograph" — and does not need the user to find
// where it will measure from.
//
// Drawn *inside* the cell and 4px clear of the edge, so it
// never touches the thumbnail and never changes a single
// dimension. Selecting adds ink and moves nothing.
Rectangle {
visible: cell.anchor;
visible: cell.selected;
x: 4px;
y: 4px;
width: parent.width - 8px;
height: parent.height - 8px;
background: transparent;
border-width: 1px;
// Two, not one: this is now the whole of the cue, and it
// has to survive being read across forty cells at arm's
// length against a thumbnail of any brightness.
border-width: 2px;
border-color: Theme.selected-ring;
border-radius: Theme.radius-sm;
}