Move the library grid's cells and selection onto their own Slint global

AppWindow carried the grid's loaded window of cells, the keyboard cursor,
drag and drop, the held-row long-press state, columns and cell size, the
scroll and viewport bookkeeping, the photo roll's pick and centre-request,
and the local-only/reorder/collection-filing gestures that act on a
selection, as properties and callbacks on the root component. That state now
lives in the `Library` global declared in library.slint, next to the structs
(LibraryCell, TimelineBar, KeywordRow, PersonChip) it and the grid's other
components already share; Rust reaches it through
window.global::<Library>() instead of window.set_/get_/on_/invoke_ on the
root, the same change collections.slint's `Collections` global made for the
sidebar.

library-visible stays on AppWindow: it is computed from active-page and
active-view, the shell's own routing state, which a global cannot read.
Everything else still prefixed library- — the timeline, the filter bar,
ratings and flags, keywording, and the routes and status lines — stays on
the window for now and moves in the commits that follow.
This commit is contained in:
2026-09-20 21:42:48 +02:00
parent 3bfa73d1e1
commit 402dcdc24c
18 changed files with 992 additions and 900 deletions
+51 -158
View File
@@ -7,7 +7,7 @@ import { CropOverlay } from "crop.slint";
import { HistoryPanel, HistoryRow, Steps } from "history.slint";
import { LaunchScreen } from "launch.slint";
import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.slint";
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChip } from "library.slint";
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChip, Library } from "library.slint";
import { GestureRow } from "gestures.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
import { CollectionsPanel, CollectionRow, Collections, OfflinePrompt, CollectionMenu,
@@ -32,6 +32,7 @@ export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole }
// panels take their wiring this way and why there is one global per family.
export { Develop, Capture, Levels, Peaking, Adjustments, Framing, Transfer, Masking, Repair, Steps }
export { Collections }
export { Library }
/// Which top-level screen is on display. Exactly one at a time — replacing
/// the `show-launch` / `show-library` / `show-identity` booleans, with
@@ -288,8 +289,6 @@ export component AppWindow inherits Window {
/// the two can drift apart again.
out property <bool> library-visible:
root.active-page == Page.none && root.active-view == View.library;
in-out property <[LibraryCell]> library-cells;
in property <int> library-total: 0;
// --- background activity (FR-CAT-1, FR-NC-6c) ---
//
// Every background job in one register, filled by `activity.rs`. The
@@ -338,28 +337,9 @@ export component AppWindow inherits Window {
in property <bool> library-offline: false;
in property <string> library-offline-reason: "";
in property <string> library-offline-since: "";
// Narrows the grid to images whose RAW is stored locally — the ones that
// can actually be opened while offline. Off by default: the catalog is the
// library, and hiding most of it the moment a connection drops would read
// as data loss rather than as a filter.
in-out property <bool> library-local-only: false;
in property <int> library-local-count: 0;
callback library-toggle-local-only();
/// Whether the grid is narrowed to the timeline's visible span.
in-out property <bool> library-range-active: false;
callback library-toggle-date-range();
/// TRACES: FR-CAT-5
callback library-clear-selection();
callback library-select-all();
/// TRACES: FR-CAT-7
/// Whether the grid is showing something with a manual order to change —
/// a single manual collection, not a set and not a saved filter.
in property <bool> library-reorderable: false;
/// Move the selection beside the photograph at this row of the loaded
/// window: after it where the drop landed on the cell's trailing half,
/// before it otherwise.
callback library-reorder-to(int, bool);
callback library-collection-from-selection(string);
/// TRACES: FR-CAT-6
in property <string> library-range-from;
in property <string> library-range-to;
@@ -425,7 +405,6 @@ export component AppWindow inherits Window {
callback library-timeline-pinch(float);
callback library-timeline-pan(float);
callback library-timeline-zoom(int);
in-out property <int> library-columns: 1;
in property <bool> library-syncing: false;
/// TRACES: FR-CAT-3 | FR-NC-3
/// The whole-library thumbnail pass, launched from the settings page.
@@ -496,42 +475,10 @@ export component AppWindow inherits Window {
callback identity-check-coverage();
callback identity-delete-all();
callback library-thumbnail-all();
in-out property <length> library-cell-size: 180px;
/// Whether this session is a touch one, which decides if the rating strip
/// waits to be hovered or stands open. Seeded from the platform by Rust
/// and latched by the grid on the first finger it sees.
in-out property <bool> library-touched: false;
callback library-zoom-cells(int);
/// A pinch step on the grid: the ratio since the last update.
callback library-pinch-cells(float);
/// A pinch on the grid has begun, so the press that opened it was not one.
callback library-pinch-started();
in property <int> library-scroll-to: 0;
in property <int> library-scroll-token: 0;
/// Which row of the loaded window is the photograph currently open in
/// develop, so the roll can mark it. `-1` when it is not in the window.
in property <int> library-roll-current: -1;
/// Whether the photo roll is out. Owned here rather than by the roll, so
/// it survives the develop view being torn down and rebuilt.
in-out property <bool> roll-open: false;
/// Centre the roll on the open photograph the next time the strip settles.
///
/// Raised by Rust when a develop session begins — a photograph opened from
/// the grid — and cleared by the strip itself once honoured. Owned here for
/// the same reason `roll-open` is: the develop view is rebuilt whenever an
/// overlay screen covers it, and a request raised before that must survive
/// it. See `PhotoRoll::centre-request`.
in-out property <bool> library-roll-centre: false;
/// A photograph was chosen from the roll: the row within the loaded
/// window, which is what a cell click reports too.
callback library-roll-pick(int);
callback library-sync-now();
callback library-columns-changed();
callback library-scrolled(int);
/// How many cells the grid's viewport shows at once. Rust sizes the
/// loaded window from it — see `library_ui::window_move`.
callback library-viewport-cells(int);
callback library-cell-clicked(int);
callback library-rescan();
/// Grid → launch screen, to pick a different folder or account.
callback library-change();
@@ -559,11 +506,6 @@ export component AppWindow inherits Window {
/// Ask about a collection's local copies: the tray on a row, or a long
/// press anywhere on it.
callback collection-offline-menu(int);
/// TRACES: FR-CAT-7 | FR-UI-4
/// File the grid's selection in a collection without dragging it there:
/// the target's id, and whether to take the images out of the collection
/// currently being shown.
callback library-file-in-collection(int, bool);
/// TRACES: FR-CAT-5 | FR-CAT-6
/// Keywording the grid's selection. The catalog has been searchable by
/// keyword since it existed and there was nowhere to type one; this is it.
@@ -583,58 +525,9 @@ export component AppWindow inherits Window {
/// Take a keyword off the selection. Never deletes the keyword itself —
/// it stays in the vocabulary and on every other photograph that carries it.
callback library-unassign-keyword(string);
/// TRACES: FR-UI-2
/// Whether a tap in the grid selects rather than opens, and the button
/// that turns it on. The long press does the same thing without it.
in property <bool> library-select-mode: false;
callback library-toggle-select-mode();
/// TRACES: FR-CAT-7 | FR-UI-4
/// The row a press has held long enough to pick up, or `-1`.
///
/// Set by the same hold that turns on selection mode and cleared by the
/// grid when the press ends — `in-out` because both ends write it. See
/// `held-row` in `library.slint` for what it draws and what it stops.
in-out property <int> library-held-row: -1;
/// A press on a cell ended, so the long-press timer can be cancelled.
callback library-cell-press-ended();
// --- drag and drop ---
//
// Slint's own `DragArea`/`DropArea` carry this: pointer capture, the
// click-versus-drag threshold, arbitration against the grid's Flickable,
// and the image drawn under the cursor. So there is no badge to position
// and no hover state to mirror here — only the payload, which Rust builds
// from the selection, and the count the header shows.
/// The drag payload, built by Rust from the current selection.
pure callback library-drag-payload() -> data-transfer;
/// The bitmap that travels under the cursor: one thumbnail, or a fanned
/// stack where several images are being dragged. Composited in Rust.
in property <image> library-drag-image;
/// A drag began on a cell, so an unselected one can be promoted before the
/// payload is read.
callback library-drag-started(int);
callback library-drag-finished();
in property <int> library-selected-count: 0;
callback library-cell-pressed(int, bool, bool);
callback library-remove-from-collection();
// --- where the selection is filed (FR-CAT-7) ---------------------------
//
// `library-remove-from-collection` can only ever mean the collection the
// grid is scoped to — it is the only one that button can name. Opens the
// membership sheet, whose state (`Collections.membership-open` and
// `.membership-rows`) is on the sidebar's own global — only the library
// side knows the selection has changed enough to be worth recomputing
// the sheet against.
callback library-open-membership();
// 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 <int> library-cursor: -1;
callback library-move-cursor(int, bool);
callback library-open-cursor();
// --- ratings and flags (FR-CAT-5, FR-CULL-4) ---
//
@@ -1560,7 +1453,7 @@ in property <bool> panel-visible: true;
height: 100%;
rows: Collections.collection-rows;
selected-id: root.collection-selected;
total-images: root.library-total;
total-images: Library.library-total;
error: Collections.collection-error;
// TRACES: FR-NC-6a
@@ -1592,7 +1485,7 @@ in property <bool> panel-visible: true;
rename-cancel() => { Collections.collection-rename-cancel(); }
trash-count: Collections.trash-count;
trash-label: Collections.trash-label;
selected-count: root.library-selected-count;
selected-count: Library.library-selected-count;
dropped-on(id) => { Collections.collection-dropped(id); }
collection-drag-payload(id) => { return Collections.collection-drag-payload(id); }
@@ -1610,8 +1503,8 @@ in property <bool> panel-visible: true;
// 768 logical pixels, which fits the title and the status
// line but not the library's buttons beside them.
expanded: root.expanded;
cells: root.library-cells;
total: root.library-total;
cells: Library.library-cells;
total: Library.library-total;
scanning: root.library-scanning;
opening: root.library-opening;
scan-status: root.library-status;
@@ -1621,17 +1514,17 @@ in property <bool> panel-visible: true;
offline-reason: root.library-offline-reason;
offline-since: root.library-offline-since;
retry-connection() => { root.library-retry-connection(); }
local-only: root.library-local-only;
local-count: root.library-local-count;
toggle-local-only() => { root.library-toggle-local-only(); }
local-only: Library.library-local-only;
local-count: Library.library-local-count;
toggle-local-only() => { Library.library-toggle-local-only(); }
range-active: root.library-range-active;
toggle-date-range() => { root.library-toggle-date-range(); }
clear-selection() => { root.library-clear-selection(); }
select-all() => { root.library-select-all(); }
reorderable: root.library-reorderable;
reorder-to(row, after) => { root.library-reorder-to(row, after); }
clear-selection() => { Library.library-clear-selection(); }
select-all() => { Library.library-select-all(); }
reorderable: Library.library-reorderable;
reorder-to(row, after) => { Library.library-reorder-to(row, after); }
collection-from-selection(name) => {
root.library-collection-from-selection(name);
Library.library-collection-from-selection(name);
}
range-from: root.library-range-from;
range-to: root.library-range-to;
@@ -1653,7 +1546,7 @@ in property <bool> panel-visible: true;
timeline-label: root.library-timeline-label;
window-label: root.library-window-label;
offset: root.library-offset;
selected-count: root.library-selected-count;
selected-count: Library.library-selected-count;
scope-label: Collections.collection-scope-label;
settings-armed: Transfer.armed;
paste-settings-to-selection => {
@@ -1664,7 +1557,7 @@ in property <bool> panel-visible: true;
// sheet says it would act on and what the apply handler
// reads back to decide it means the batch.
open-presets => {
root.preset-apply-count = root.library-selected-count;
root.preset-apply-count = Library.library-selected-count;
root.preset-can-save = false;
root.presets-open = true;
}
@@ -1689,23 +1582,23 @@ in property <bool> panel-visible: true;
timeline-pan(d) => { root.library-timeline-pan(d); }
timeline-zoom(d) => { root.library-timeline-zoom(d); }
syncing: root.library-syncing;
requested-cell-size: root.library-cell-size;
touched <=> root.library-touched;
zoom-cells(d) => { root.library-zoom-cells(d); }
pinch-cells(r) => { root.library-pinch-cells(r); }
pinch-started() => { root.library-pinch-started(); }
scroll-to: root.library-scroll-to;
scroll-token: root.library-scroll-token;
requested-cell-size: Library.library-cell-size;
touched <=> Library.library-touched;
zoom-cells(d) => { Library.library-zoom-cells(d); }
pinch-cells(r) => { Library.library-pinch-cells(r); }
pinch-started() => { Library.library-pinch-started(); }
scroll-to: Library.library-scroll-to;
scroll-token: Library.library-scroll-token;
sync-now() => { root.library-sync-now(); }
columns-changed(n) => {
root.library-columns = n;
Library.library-columns = n;
// Which cell begins a row just changed, and month headings sit on
// row-leading cells — so they must be recomputed, not just moved.
root.library-columns-changed();
Library.library-columns-changed();
}
scrolled(i) => { root.library-scrolled(i); }
viewport-cells-changed(n) => { root.library-viewport-cells(n); }
cell-clicked(i) => { root.library-cell-clicked(i); }
scrolled(i) => { Library.library-scrolled(i); }
viewport-cells-changed(n) => { Library.library-viewport-cells(n); }
cell-clicked(i) => { Library.library-cell-clicked(i); }
rescan() => { root.library-rescan(); }
can-import: root.import-supported;
open-import() => { root.import-open(); }
@@ -1713,35 +1606,35 @@ in property <bool> panel-visible: true;
open-people() => { root.identity-open(); }
cell-pressed(i, ctrl, shift) => {
root.library-cell-pressed(i, ctrl, shift);
Library.library-cell-pressed(i, ctrl, shift);
}
cell-press-ended() => { root.library-cell-press-ended(); }
select-mode: root.library-select-mode;
toggle-select-mode() => { root.library-toggle-select-mode(); }
held-row <=> root.library-held-row;
cell-press-ended() => { Library.library-cell-press-ended(); }
select-mode: Library.library-select-mode;
toggle-select-mode() => { Library.library-toggle-select-mode(); }
held-row <=> Library.library-held-row;
// The sidebar's rows, not a second model: the sheet files
// into the same tree the sidebar draws.
collections: Collections.collection-rows;
file-in-collection(id, moves) => {
root.library-file-in-collection(id, moves);
Library.library-file-in-collection(id, moves);
}
keywords: root.library-keywords;
keywords-opened() => { root.library-keywords-opened(); }
assign-keyword(word) => { root.library-assign-keyword(word); }
unassign-keyword(word) => { root.library-unassign-keyword(word); }
cursor: root.library-cursor;
cursor: Library.library-cursor;
move-cursor(delta, extend) => {
root.library-move-cursor(delta, extend);
Library.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); }
drag-finished() => { root.library-drag-finished(); }
open-cursor() => { Library.library-open-cursor(); }
drag-image: Library.library-drag-image;
drag-payload() => { return Library.library-drag-payload(); }
drag-started(i) => { Library.library-drag-started(i); }
drag-finished() => { Library.library-drag-finished(); }
remove-from-collection() => {
root.library-remove-from-collection();
Library.library-remove-from-collection();
}
open-membership() => { root.library-open-membership(); }
open-membership() => { Library.library-open-membership(); }
filter-people: root.library-filter-people;
filter-people-all: root.library-filter-people-all;
@@ -1805,7 +1698,7 @@ in property <bool> panel-visible: true;
position: root.total > 0 ? root.index + 1 + " / " + root.total : "";
// Only where a library was opened; command-line files have no
// grid to return to.
can-return-to-library: root.library-total > 0;
can-return-to-library: Library.library-total > 0;
panel-visible: root.panel-visible;
export-label: root.export-label;
export-busy: root.export-busy;
@@ -2657,7 +2550,7 @@ in property <bool> panel-visible: true;
// instead — the controls ride on top of the roll's
// band, at whatever height it currently stands.
property <length> roll-clear:
root.library-total > 0
Library.library-total > 0
? (root.roll-open
? Theme.roll-strip + Theme.roll-reach
: Theme.roll-reach)
@@ -2770,14 +2663,14 @@ in property <bool> panel-visible: true;
//
// Last in the canvas, so it draws over the photograph
// rather than under it.
if root.library-total > 0: PhotoRoll {
if Library.library-total > 0: PhotoRoll {
width: 100%;
height: 100%;
cells: root.library-cells;
current: root.library-roll-current;
cells: Library.library-cells;
current: Library.library-roll-current;
open <=> root.roll-open;
centre-request <=> root.library-roll-centre;
pick(i) => { root.library-roll-pick(i); }
centre-request <=> Library.library-roll-centre;
pick(i) => { Library.library-roll-pick(i); }
}
// Report size changes so the render target can be resized to
@@ -3179,7 +3072,7 @@ in property <bool> panel-visible: true;
width: 100%;
height: 100%;
open: Collections.membership-open;
selected-count: root.library-selected-count;
selected-count: Library.library-selected-count;
rows: Collections.membership-rows;
remove(id) => { Collections.membership-remove(id); }
dismiss() => { Collections.membership-dismiss(); }
+123
View File
@@ -479,6 +479,129 @@ export struct LibraryCell {
burst-representative: bool,
}
// TRACES: FR-CAT-3 | FR-CAT-4 | FR-CAT-5 | FR-CAT-6 | FR-CAT-7 | FR-CULL-4 | FR-CULL-5
// The library grid's own state: the loaded window of cells, the selection,
// the timeline, the filter bar, ratings and flags, keywording, drag and drop,
// and the routes into and out of the screen.
//
// A global rather than properties and callbacks on the window root — see
// `session.slint` for why the develop panels stopped taking their wiring
// through the window root, and `collections.slint` for the sidebar's own
// global; the same reasoning applies here. What is shared with the sidebar
// (the collection the grid is scoped to, whether the sidebar is shown) stays
// on the window, because `run()`'s panel-layout wiring and the sidebar's own
// Rust still reach it there.
export global Library {
in-out property <[LibraryCell]> library-cells;
in property <int> library-total: 0;
// Narrows the grid to images whose RAW is stored locally — the ones that
// can actually be opened while offline. Off by default: the catalog is the
// library, and hiding most of it the moment a connection drops would read
// as data loss rather than as a filter.
in-out property <bool> library-local-only: false;
in property <int> library-local-count: 0;
callback library-toggle-local-only();
/// TRACES: FR-CAT-5
callback library-clear-selection();
callback library-select-all();
/// TRACES: FR-CAT-7
/// Whether the grid is showing something with a manual order to change —
/// a single manual collection, not a set and not a saved filter.
in property <bool> library-reorderable: false;
/// Move the selection beside the photograph at this row of the loaded
/// window: after it where the drop landed on the cell's trailing half,
/// before it otherwise.
callback library-reorder-to(int, bool);
callback library-collection-from-selection(string);
in-out property <int> library-columns: 1;
in-out property <length> library-cell-size: 180px;
/// Whether this session is a touch one, which decides if the rating strip
/// waits to be hovered or stands open. Seeded from the platform by Rust
/// and latched by the grid on the first finger it sees.
in-out property <bool> library-touched: false;
callback library-zoom-cells(int);
/// A pinch step on the grid: the ratio since the last update.
callback library-pinch-cells(float);
/// A pinch on the grid has begun, so the press that opened it was not one.
callback library-pinch-started();
in property <int> library-scroll-to: 0;
in property <int> library-scroll-token: 0;
/// Which row of the loaded window is the photograph currently open in
/// develop, so the roll can mark it. `-1` when it is not in the window.
in property <int> library-roll-current: -1;
/// Centre the roll on the open photograph the next time the strip settles.
///
/// Raised by Rust when a develop session begins — a photograph opened from
/// the grid — and cleared by the strip itself once honoured. Owned here for
/// the same reason `roll-open` is: the develop view is rebuilt whenever an
/// overlay screen covers it, and a request raised before that must survive
/// it. See `PhotoRoll::centre-request`.
in-out property <bool> library-roll-centre: false;
/// A photograph was chosen from the roll: the row within the loaded
/// window, which is what a cell click reports too.
callback library-roll-pick(int);
callback library-columns-changed();
callback library-scrolled(int);
/// How many cells the grid's viewport shows at once. Rust sizes the
/// loaded window from it — see `library_ui::window_move`.
callback library-viewport-cells(int);
callback library-cell-clicked(int);
/// TRACES: FR-CAT-7 | FR-UI-4
/// File the grid's selection in a collection without dragging it there:
/// the target's id, and whether to take the images out of the collection
/// currently being shown.
callback library-file-in-collection(int, bool);
/// TRACES: FR-UI-2
/// Whether a tap in the grid selects rather than opens, and the button
/// that turns it on. The long press does the same thing without it.
in property <bool> library-select-mode: false;
callback library-toggle-select-mode();
/// TRACES: FR-CAT-7 | FR-UI-4
/// The row a press has held long enough to pick up, or `-1`.
///
/// Set by the same hold that turns on selection mode and cleared by the
/// grid when the press ends — `in-out` because both ends write it. See
/// `held-row` in `library.slint` for what it draws and what it stops.
in-out property <int> library-held-row: -1;
/// A press on a cell ended, so the long-press timer can be cancelled.
callback library-cell-press-ended();
// --- drag and drop ---
//
// Slint's own `DragArea`/`DropArea` carry this: pointer capture, the
// click-versus-drag threshold, arbitration against the grid's Flickable,
// and the image drawn under the cursor. So there is no badge to position
// and no hover state to mirror here — only the payload, which Rust builds
// from the selection, and the count the header shows.
/// The drag payload, built by Rust from the current selection.
pure callback library-drag-payload() -> data-transfer;
/// The bitmap that travels under the cursor: one thumbnail, or a fanned
/// stack where several images are being dragged. Composited in Rust.
in property <image> library-drag-image;
/// A drag began on a cell, so an unselected one can be promoted before the
/// payload is read.
callback library-drag-started(int);
callback library-drag-finished();
in property <int> library-selected-count: 0;
callback library-cell-pressed(int, bool, bool);
callback library-remove-from-collection();
// --- where the selection is filed (FR-CAT-7) ---------------------------
//
// `library-remove-from-collection` can only ever mean the collection the
// grid is scoped to — it is the only one that button can name. Opens the
// membership sheet, whose state (`Collections.membership-open` and
// `.membership-rows`) is on the sidebar's own global — only the library
// side knows the selection has changed enough to be worth recomputing
// the sheet against.
callback library-open-membership();
// 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 <int> library-cursor: -1;
callback library-move-cursor(int, bool);
callback library-open-cursor();
}
// The photo roll: the grid's loaded window along the foot of the develop view.
//
// # Why this exists