Merge master into tablet-selection

Two real conflicts, both from work that landed either side of the same
lines rather than against them.

`lib.rs`: the settings controller was hoisted above the People screen's
wiring, and Android's thumbnail-tier eviction registered itself at the
same point. Independent, so both stay.

`library.rs`: manual collection ordering and burst folding each added a
clause to the same two queries. The scoped range read now carries both —
the folding matters there for one step further on than it does in the
grid, because a collapsed burst is one cell, so an ordinal counted over a
list still holding every frame names a photograph several places away
from the one the user pointed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 22:52:42 +02:00
co-authored by Claude Opus 5
54 changed files with 7566 additions and 166 deletions
+105
View File
@@ -468,6 +468,16 @@ export struct LibraryCell {
// 0 unflagged, 1 pick, 2 reject. Independent of the stars: rejecting a
// four-star frame is a normal thing to do mid-cull.
flag: int,
// Frames in the burst this cell belongs to (FR-CULL-5), itself included; 0 where it
// belongs to none, which is most of a library. A burst that is collapsed
// draws only its representative, so on that cell this is the count of what
// is hidden behind it — the reason it is shown at all.
burst-count: int,
// Whether the group is currently open. Drawn differently rather than
// hidden: a burst the user has expanded is the one thing on screen that
// needs a way back, and a control that disappears once used is a control
// nobody finds twice.
burst-expanded: bool,
}
// The photo roll: the grid's loaded window along the foot of the develop view.
@@ -860,6 +870,9 @@ component HeaderActions inherits HorizontalLayout {
callback export-selection();
callback cancel-export();
callback paste-settings-to-selection();
/// TRACES: FR-DEV-6
/// Open the named-preset sheet over the selection.
callback open-presets();
callback remove-from-collection();
/// Open the sheet that files the selection in a collection.
callback add-to-collection();
@@ -941,6 +954,20 @@ component HeaderActions inherits HorizontalLayout {
clicked => { root.paste-settings-to-selection(); }
}
// TRACES: FR-DEV-6
// The saved settings, beside the copied ones.
//
// Gated on the selection alone, unlike the paste beside it: that button
// needs a clipboard *this session*, where the preset list is whatever the
// photographer saved last month. Requiring an armed clipboard here would
// hide the saved presets behind an unrelated action — which is the shape
// of bug that makes a feature only its author knows about (FR-UI-4).
if root.selected-count > 0: Button {
text: "Presets";
y: root.centred ? (root.row-height - self.height) / 2 : 0;
clicked => { root.open-presets(); }
}
// TRACES: FR-EXP-7 | NFR-ARCH-3
// Export the selection, and stop the batch that is running.
//
@@ -1148,6 +1175,11 @@ export component LibraryGrid inherits Rectangle {
callback cell-clicked(int);
/// A star was clicked on a cell: row, and the rating 0..5.
callback cell-rated(int, int);
/// The burst mark on a cell was clicked (FR-CULL-5): open the group, or fold it back
/// up. Which of the two is decided in Rust, from what the catalog says the
/// group is currently doing, so the mark cannot get out of step with the
/// query that actually hides the frames.
callback burst-toggled(int);
/// Whether the grid is currently listing the trash rather than the
/// library. Suppresses the per-cell trash target, which would be inert
/// there — `plan_trash` skips an already-trashed image — and offering a
@@ -1433,6 +1465,8 @@ export component LibraryGrid inherits Rectangle {
in property <bool> settings-armed: false;
in property <string> settings-summary;
callback paste-settings-to-selection();
/// TRACES: FR-DEV-6
callback open-presets();
// TRACES: FR-EXP-7
// Exporting the selection. The grid owns neither the settings that decide
@@ -1819,6 +1853,7 @@ export component LibraryGrid inherits Rectangle {
export-selection => { root.export-selection(); }
cancel-export => { root.cancel-export(); }
paste-settings-to-selection => { root.paste-settings-to-selection(); }
open-presets => { root.open-presets(); }
remove-from-collection => { root.remove-from-collection(); }
select-mode: root.select-mode;
toggle-select-mode => { root.toggle-select-mode(); }
@@ -1904,6 +1939,7 @@ export component LibraryGrid inherits Rectangle {
export-selection => { root.export-selection(); }
cancel-export => { root.cancel-export(); }
paste-settings-to-selection => { root.paste-settings-to-selection(); }
open-presets => { root.open-presets(); }
remove-from-collection => { root.remove-from-collection(); }
select-mode: root.select-mode;
toggle-select-mode => { root.toggle-select-mode(); }
@@ -3226,6 +3262,75 @@ export component LibraryGrid inherits Rectangle {
rate(n) => { root.cell-rated(i, n); }
trash() => { root.cell-trashed(i); }
}
// The burst mark (FR-CULL-5): how many frames this
// moment holds,
// and the way in and out of them.
//
// A child of `cell-touch` for exactly the reason the
// stars above are: a click here must not also reach
// `cell-clicked` and throw the user into develop, and
// children are hit-tested before the element they sit
// in. Unlike the stars it is never hidden — a collapsed
// burst is standing in for frames that are not on
// screen, and there has to be something visible saying
// so whether or not a pointer is anywhere near.
//
// Bottom left, clear of the centred star strip and of
// both top corners, which the flag and the collection
// badge already have.
if cell.burst-count > 1: Rectangle {
x: 6px;
y: parent.height - self.height - 26px;
width: 30px;
height: 18px;
// The pile behind the top card, drawn only while the
// group is folded up. It is the whole of the "there
// is more than one of these" cue; once the burst is
// open the frames themselves say it.
Rectangle {
x: 3px;
y: -3px;
width: parent.width - 3px;
height: parent.height;
visible: !cell.burst-expanded;
background: Theme.surface;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: Theme.rule;
}
Rectangle {
width: 100%;
height: 100%;
background: cell.burst-expanded ? Theme.selected
: Theme.surface;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: cell.burst-expanded ? Theme.selected-ring
: Theme.rule;
Text {
width: 100%;
height: 100%;
horizontal-alignment: center;
vertical-alignment: center;
// No "of": the number is the size of the
// group, and a cell this small cannot
// afford a word to say so.
text: cell.burst-count;
color: Theme.ink;
font-size: 10px;
font-weight: 700;
}
}
TouchArea {
mouse-cursor: pointer;
clicked => { root.burst-toggled(i); }
}
}
}
}
// Where the run would land. A bar in the gutter beside the