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:
@@ -16,3 +16,10 @@ Cargo.lock.bak
|
|||||||
# tools/film-profiles/convert.py --fetch. Not source: the converted
|
# tools/film-profiles/convert.py --fetch. Not source: the converted
|
||||||
# profiles in core/dr-film/profiles are.
|
# profiles in core/dr-film/profiles are.
|
||||||
tools/film-profiles/upstream/
|
tools/film-profiles/upstream/
|
||||||
|
|
||||||
|
# flatpak-builder's cache and its output tree. `packaging/flatpak/` holds the
|
||||||
|
# manifest, which is source; everything a build derives from it is not — and
|
||||||
|
# `.flatpak-builder/` in particular caches an unpacked copy of the whole
|
||||||
|
# checkout, so it is larger than the repository it sits in.
|
||||||
|
/.flatpak-builder/
|
||||||
|
/build/
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ One commit per change. If you fixed two things, that is two commits.
|
|||||||
| [`docs/architecture.md`](docs/architecture.md) | Anything touching the render path, catalog or sync |
|
| [`docs/architecture.md`](docs/architecture.md) | Anything touching the render path, catalog or sync |
|
||||||
| [`docs/code-health.md`](docs/code-health.md) | Deciding what to work on; grades each seam by what it costs |
|
| [`docs/code-health.md`](docs/code-health.md) | Deciding what to work on; grades each seam by what it costs |
|
||||||
| [`docs/technical-debt.md`](docs/technical-debt.md) | Something looks wrong — check it was not chosen |
|
| [`docs/technical-debt.md`](docs/technical-debt.md) | Something looks wrong — check it was not chosen |
|
||||||
|
| [`docs/distribution.md`](docs/distribution.md) | Packaging a build, or adding a permission to one |
|
||||||
| [`docs/requirements.md`](docs/requirements.md) | Reference, not reading |
|
| [`docs/requirements.md`](docs/requirements.md) | Reference, not reading |
|
||||||
|
|
||||||
`technical-debt.md` is the one to check before "fixing" anything surprising.
|
`technical-debt.md` is the one to check before "fixing" anything surprising.
|
||||||
|
|||||||
@@ -2,17 +2,25 @@
|
|||||||
|
|
||||||
A cross-platform, non-destructive RAW photo editor for Linux and Android.
|
A cross-platform, non-destructive RAW photo editor for Linux and Android.
|
||||||
|
|
||||||
**Status:** early. v0.1 is a remote library viewer — see
|
**Status:** 0.9.0, and no longer a spike. A library opens, culls, develops and
|
||||||
[docs/milestone-v0.1.md](docs/milestone-v0.1.md).
|
exports on both platforms, across eight tagged releases. What is *not*
|
||||||
|
built is written down rather than merely absent — see
|
||||||
|
[docs/outstanding.md](docs/outstanding.md) for the requirements that have no
|
||||||
|
implementation and why, and [docs/technical-debt.md](docs/technical-debt.md)
|
||||||
|
for the compromises that were chosen.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
| Document | Contents |
|
| Document | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [requirements.md](docs/requirements.md) | What the software must do — 122 numbered requirements |
|
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to land a first change without reading the rest |
|
||||||
|
| [requirements.md](docs/requirements.md) | What the software must do — 179 numbered requirements |
|
||||||
| [architecture.md](docs/architecture.md) | How it is built — crates, GPU pipeline, data model, sync |
|
| [architecture.md](docs/architecture.md) | How it is built — crates, GPU pipeline, data model, sync |
|
||||||
| [milestone-v0.1.md](docs/milestone-v0.1.md) | The first buildable milestone |
|
| [technical-debt.md](docs/technical-debt.md) | Compromises taken deliberately, each with the condition that retires it |
|
||||||
| [faces.md](docs/faces.md) | Face detection and identity — the models, the licence problem, and what S14 measures |
|
| [outstanding.md](docs/outstanding.md) | What is not built, and whether that is a decision or a gap |
|
||||||
|
| [code-health.md](docs/code-health.md) | What a contribution costs, per seam, measured |
|
||||||
|
| [traceability.md](docs/traceability.md) | Generated: which requirement is claimed by which file |
|
||||||
|
| [faces.md](docs/faces.md) | Face detection and identity — the models, the licence problem, and what S14 measured |
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
@@ -28,21 +36,48 @@ Android (containerised toolchain, see [docker/android](docker/android/README.md)
|
|||||||
./docker/android/build.sh cargo ndk -t arm64-v8a build --release
|
./docker/android/build.sh cargo ndk -t arm64-v8a build --release
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Git LFS is required for the model weights, and the toolchain pins itself.
|
||||||
|
[CONTRIBUTING.md](CONTRIBUTING.md) has the details and the four commands CI
|
||||||
|
will run against what you send.
|
||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
Working: workspace, GPU context and compute pass, adaptive Slint shell, Android
|
**Working.** A catalog over a local folder, a Nextcloud account, or a folder a
|
||||||
cross-compilation of the core crates.
|
sync client keeps in virtual-files mode — where a placeholder is treated as the
|
||||||
|
photograph rather than as a one-byte file. A virtualised library grid with a
|
||||||
|
capture-time timeline, ratings, labels, keywords, collections and a trash that
|
||||||
|
survives a crash mid-operation. Card ingest. Face detection and identity, with
|
||||||
|
the index syncing between devices. A develop pipeline of fifteen declared
|
||||||
|
operations fused into a single compute dispatch, plus the neighbourhood
|
||||||
|
operations that cannot be — clarity, texture, capture sharpening, noise
|
||||||
|
reduction, lens correction, spectral film simulation. Crop, straighten, spot
|
||||||
|
removal, gradient and subject-segmentation masks, named presets, and a
|
||||||
|
generated panel that no operation in `ui/` is allowed to name. Export to JPEG,
|
||||||
|
PNG and 8- or 16-bit TIFF with resize and output sharpening.
|
||||||
|
|
||||||
**Not yet working:** the zero-copy display path. The build currently uploads
|
**The zero-copy display path works on desktop.** The compute pass writes a
|
||||||
frames through the CPU, which is exactly what
|
texture that Slint composites directly, which is what
|
||||||
[ARCH §6.1](docs/architecture.md) forbids — measured at 96% of frame time at
|
[ARCH §6.1](docs/architecture.md) requires; the readback it forbids costs 96%
|
||||||
4K. Replacing it is spike S1, the project's highest priority.
|
of frame time at 4K, and
|
||||||
|
|
||||||
```
|
```bash
|
||||||
cargo run -p dr-gpu --example bench --features readback
|
cargo run -p dr-gpu --example bench --features readback
|
||||||
```
|
```
|
||||||
|
|
||||||
reproduces that measurement.
|
still reproduces that measurement. **The one exception is the Android develop
|
||||||
|
view**, which reads the frame back through the CPU because zero-copy there
|
||||||
|
needs wgpu's Vulkan swapchain, and that tears a portrait window on a tablet
|
||||||
|
whose panel is mounted landscape. It is debt, not a revision of the rule: the
|
||||||
|
reasoning, the on-device measurements that forced it, and the three separate
|
||||||
|
things any one of which would remove it are in
|
||||||
|
[technical-debt.md TD-1](docs/technical-debt.md).
|
||||||
|
|
||||||
|
**Not built.** Plugins, compare and survey culling, focus peaking, burst
|
||||||
|
grouping, AI denoise, tiled and progressive rendering, and most of the Android
|
||||||
|
platform integration beyond running. The performance targets in §4.1 are
|
||||||
|
unverified rather than unmet — the per-commit benchmark suite §8 requires does
|
||||||
|
not exist, so nothing fails a build on a regression.
|
||||||
|
[docs/outstanding.md](docs/outstanding.md) is the list, with the reasoning.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,47 @@ fn android_main(app: slint::android::AndroidApp) {
|
|||||||
// After the data dir and before anything asks whether a model is present.
|
// After the data dir and before anything asks whether a model is present.
|
||||||
install_bundled_face_models(&app);
|
install_bundled_face_models(&app);
|
||||||
|
|
||||||
if let Err(e) = slint::android::init(app) {
|
// TRACES: FR-PLAT-AND-5
|
||||||
|
// The listener is the whole reason this is not the one-line
|
||||||
|
// `slint::android::init(app)`. Slint owns the event loop on Android, so
|
||||||
|
// the platform's lifecycle and memory events reach the application only if
|
||||||
|
// it asks for them here — and it must ask *before* the loop starts, which
|
||||||
|
// is why this sits between the data directory and `dr_ui::run`.
|
||||||
|
//
|
||||||
|
// The listener runs inside `poll_events`, on the same thread the event
|
||||||
|
// loop and every interface cache live on, which is what lets
|
||||||
|
// `dr_ui::memory` be a thread-local registry of plain `Fn()` rather than a
|
||||||
|
// cross-thread channel (see its module documentation).
|
||||||
|
//
|
||||||
|
// # Why two events and not eight
|
||||||
|
//
|
||||||
|
// FR-PLAT-AND-5 names `onTrimMemory`, whose `TRIM_MEMORY_*` levels grade
|
||||||
|
// how badly the system wants the memory back. Those levels do not exist
|
||||||
|
// here: `ComponentCallbacks2` is a Java interface implemented by an
|
||||||
|
// `Activity` or `Application`, and this app has neither — it is a bare
|
||||||
|
// `NativeActivity`, whose native callback table offers only the ungraded
|
||||||
|
// `onLowMemory`. android-activity surfaces exactly that as `LowMemory`.
|
||||||
|
// Reading the grades would mean shipping a Java subclass to forward them,
|
||||||
|
// which is a distribution-manifest change and not this one.
|
||||||
|
//
|
||||||
|
// `Stop` recovers the one grade that matters most anyway, and for free.
|
||||||
|
// It is the moment the activity stops being visible — `TRIM_MEMORY_UI_HIDDEN`
|
||||||
|
// in all but name — and it is the cheapest possible time to give memory
|
||||||
|
// back, because nothing that is freed has to be drawn again before anyone
|
||||||
|
// sees it. `Pause` deliberately does not qualify: a permission dialog or
|
||||||
|
// the share sheet pauses an activity that is still on screen behind it,
|
||||||
|
// and throwing away its render pipeline would make every such interruption
|
||||||
|
// cost a full re-render.
|
||||||
|
use slint::android::android_activity::{MainEvent, PollEvent};
|
||||||
|
if let Err(e) = slint::android::init_with_event_listener(app, |event| match event {
|
||||||
|
PollEvent::Main(MainEvent::LowMemory) => {
|
||||||
|
dr_ui::memory::relieve(dr_ui::memory::Level::Critical);
|
||||||
|
}
|
||||||
|
PollEvent::Main(MainEvent::Stop) => {
|
||||||
|
dr_ui::memory::relieve(dr_ui::memory::Level::UiHidden);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}) {
|
||||||
log::error!("Slint Android backend failed to initialise: {e}");
|
log::error!("Slint Android backend failed to initialise: {e}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@
|
|||||||
//! - [`collections`] — the collection tree and membership the UI edits
|
//! - [`collections`] — the collection tree and membership the UI edits
|
||||||
//! - [`keywords`] — the keyword vocabulary and what it is assigned to
|
//! - [`keywords`] — the keyword vocabulary and what it is assigned to
|
||||||
//! - [`faces`] — detected faces, the people they belong to, and who said so
|
//! - [`faces`] — detected faces, the people they belong to, and who said so
|
||||||
|
//! - [`bursts`] — frames that are one moment, grouped so they judge as one
|
||||||
//! - [`jobs`] — the durable background work queue
|
//! - [`jobs`] — the durable background work queue
|
||||||
//! - [`trash`] — soft delete to a folder, then permanent delete
|
//! - [`trash`] — soft delete to a folder, then permanent delete
|
||||||
//! - [`merge`] / [`sync`] — cross-device merging of collections and keywords
|
//! - [`merge`] / [`sync`] — cross-device merging of collections and keywords
|
||||||
@@ -33,6 +34,7 @@ use std::path::Path;
|
|||||||
use dr_types::{Availability, ImageId};
|
use dr_types::{Availability, ImageId};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
pub mod bursts;
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod collections;
|
pub mod collections;
|
||||||
pub mod dedup;
|
pub mod dedup;
|
||||||
@@ -63,7 +65,7 @@ pub use query::{Query, Sort};
|
|||||||
pub use rating::{Judgement, MAX_RATING};
|
pub use rating::{Judgement, MAX_RATING};
|
||||||
pub use scan::{DirAction, DirState, EntryAction, ScanOutcome};
|
pub use scan::{DirAction, DirState, EntryAction, ScanOutcome};
|
||||||
pub use trash::{TrashedImage, TRASH_DIR};
|
pub use trash::{TrashedImage, TRASH_DIR};
|
||||||
pub use walk::{ensure_root, scan_root, RootKind, ScanProgress, ScanReport};
|
pub use walk::{ensure_root, mark_root_offline, scan_root, RootKind, ScanProgress, ScanReport};
|
||||||
|
|
||||||
/// One row of the library grid.
|
/// One row of the library grid.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use rusqlite::Connection;
|
|||||||
use crate::error::CatalogError;
|
use crate::error::CatalogError;
|
||||||
|
|
||||||
/// Schema version this build writes and understands.
|
/// Schema version this build writes and understands.
|
||||||
pub const SCHEMA_VERSION: i64 = 10;
|
pub const SCHEMA_VERSION: i64 = 11;
|
||||||
|
|
||||||
/// Apply migrations up to [`SCHEMA_VERSION`].
|
/// Apply migrations up to [`SCHEMA_VERSION`].
|
||||||
///
|
///
|
||||||
@@ -98,6 +98,13 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
|
|||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if from < 11 {
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
tx.execute_batch(V11)?;
|
||||||
|
tx.pragma_update(None, "user_version", 11)?;
|
||||||
|
tx.commit()?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(from)
|
Ok(from)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +212,11 @@ pub fn v1_for_attached(schema_name: &str) -> String {
|
|||||||
/// creating it over there would fail on columns that are not there. Nothing is
|
/// creating it over there would fail on columns that are not there. Nothing is
|
||||||
/// lost by its absence — it exists to make the *grid* page quickly, and the
|
/// lost by its absence — it exists to make the *grid* page quickly, and the
|
||||||
/// grid never reads across an attachment.
|
/// grid never reads across an attachment.
|
||||||
|
///
|
||||||
|
/// V11 is excluded on the same grounds and for the plainer reason that a merge
|
||||||
|
/// has nothing to do with it: burst grouping is rebuilt locally from local
|
||||||
|
/// signatures, and no code reads a remote catalog's `burst_*` tables. Its
|
||||||
|
/// `ALTER TABLE` would fail here anyway, being unqualifiable by the rewrite.
|
||||||
pub fn for_attached(schema_name: &str) -> String {
|
pub fn for_attached(schema_name: &str) -> String {
|
||||||
// V10 is `ALTER TABLE`, which the textual rewrite cannot qualify, so its
|
// V10 is `ALTER TABLE`, which the textual rewrite cannot qualify, so its
|
||||||
// columns are spelled out. A remote genuinely older than V10 is a real
|
// columns are spelled out. A remote genuinely older than V10 is a real
|
||||||
@@ -397,6 +409,73 @@ ALTER TABLE people ADD COLUMN ignored INTEGER NOT NULL DEFAULT 0;
|
|||||||
ALTER TABLE faces ADD COLUMN crop BLOB;
|
ALTER TABLE faces ADD COLUMN crop BLOB;
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// TRACES: FR-CULL-5
|
||||||
|
/// Burst grouping: which frames are one moment, and which one stands for it.
|
||||||
|
///
|
||||||
|
/// The reasoning behind the grouping itself is in [`crate::bursts`]; what
|
||||||
|
/// belongs here is why it is stored in three pieces rather than one.
|
||||||
|
///
|
||||||
|
/// **`images.perceptual_hash` is a column, not a table**, for the same reason
|
||||||
|
/// `content_hash` is: it is one number per image, NULL until something has had
|
||||||
|
/// the pixels in hand, and every query that wants it is already reading the
|
||||||
|
/// image row. It is local derived state — a rebuilt catalog recomputes it from
|
||||||
|
/// thumbnails — which is also why it is absent from [`for_attached`], alongside
|
||||||
|
/// the shadowing and trashing columns V2 through V5 add.
|
||||||
|
///
|
||||||
|
/// **`burst_members` is rewritten whole by every pass.** No id of its own: the
|
||||||
|
/// group is named by the image id of its earliest frame, so a burst that has not
|
||||||
|
/// changed keeps its name across a regroup and the interface can remember that
|
||||||
|
/// this one is open. There is no `bursts` table to go with it because a group
|
||||||
|
/// has no properties beyond its members — inventing a row for it would create an
|
||||||
|
/// identity that survives the grouping being rebuilt, which is precisely what
|
||||||
|
/// must not happen.
|
||||||
|
///
|
||||||
|
/// **`burst_pick` is the one thing here that is not derived**, and it is a
|
||||||
|
/// separate table so that rewriting the grouping cannot erase it. A
|
||||||
|
/// representative stored on `burst_members` would be forgotten every time a
|
||||||
|
/// frame arrived; the user would be asked the same question after every scan.
|
||||||
|
/// The same argument `people.ignored` makes in V10, one subsystem over.
|
||||||
|
///
|
||||||
|
/// **`burst_expanded` is view state in the catalog**, which is unusual enough to
|
||||||
|
/// justify. The grid is a window over an ordered query — `LIMIT n OFFSET k` —
|
||||||
|
/// so what a collapsed burst hides has to be decided by the query, or the row
|
||||||
|
/// count stops agreeing with the scrollbar and the ordinals a scrub resolves to.
|
||||||
|
/// Once SQL has to see it, this is where it lives. Nothing else reads it, and it
|
||||||
|
/// is emptied of stale groups by every pass.
|
||||||
|
const V11: &str = r#"
|
||||||
|
-- A 64-bit perceptual signature. Local derived state: NULL until something has
|
||||||
|
-- decoded the image, recomputed from thumbnails if the catalog is rebuilt, and
|
||||||
|
-- comparable only to signatures produced by the same build (`bursts`).
|
||||||
|
ALTER TABLE images ADD COLUMN perceptual_hash INTEGER;
|
||||||
|
|
||||||
|
CREATE TABLE burst_members (
|
||||||
|
-- One burst at most per image: a frame belongs to the moment it was taken
|
||||||
|
-- in, and nothing else.
|
||||||
|
image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE,
|
||||||
|
-- The image id of the burst's earliest frame. Not a foreign key by
|
||||||
|
-- accident: the leader is itself a member, so this genuinely references
|
||||||
|
-- images(id), and cascading its deletion is right.
|
||||||
|
burst_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
|
||||||
|
-- The frame the group collapses to. Exactly one per burst.
|
||||||
|
representative INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
-- Counting a burst's frames and listing them are what the grid asks for, once
|
||||||
|
-- per window; without this both are a scan of every grouped frame in the
|
||||||
|
-- library.
|
||||||
|
CREATE INDEX burst_members_burst ON burst_members(burst_id);
|
||||||
|
|
||||||
|
-- The user's own choice of representative. User data, never rewritten by a
|
||||||
|
-- grouping pass -- see the module doc above.
|
||||||
|
CREATE TABLE burst_pick (
|
||||||
|
image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Bursts the grid is currently showing in full.
|
||||||
|
CREATE TABLE burst_expanded (
|
||||||
|
burst_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
"#;
|
||||||
|
|
||||||
const V9: &str = r#"
|
const V9: &str = r#"
|
||||||
-- TRACES: FR-CULL-8
|
-- TRACES: FR-CULL-8
|
||||||
-- A record that face detection has *run* on an image, distinct from what it
|
-- A record that face detection has *run* on an image, distinct from what it
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ fn bump_generation(conn: &Connection, root: RootId, now: i64) -> Result<i64, Cat
|
|||||||
Ok(next)
|
Ok(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-CAT-9
|
/// TRACES: FR-CAT-9 | FR-PLAT-AND-2
|
||||||
/// Mark every image under a root as unreachable.
|
/// Mark every image under a root as unreachable.
|
||||||
///
|
///
|
||||||
/// The other half of FR-CAT-9's distinction: a source *proven absent* may leave
|
/// The other half of FR-CAT-9's distinction: a source *proven absent* may leave
|
||||||
@@ -445,14 +445,38 @@ fn bump_generation(conn: &Connection, root: RootId, now: i64) -> Result<i64, Cat
|
|||||||
/// the root now claims something about the files that is no longer known to be
|
/// the root now claims something about the files that is no longer known to be
|
||||||
/// true, and only reading the directories again can settle it. Pruning would
|
/// true, and only reading the directories again can settle it. Pruning would
|
||||||
/// skip them all and leave a plugged-in library showing as offline forever.
|
/// skip them all and leave a plugged-in library showing as offline forever.
|
||||||
fn mark_root_offline(conn: &Connection, root: RootId) -> Result<(), CatalogError> {
|
///
|
||||||
|
/// # Why the ETag goes with the mtime
|
||||||
|
///
|
||||||
|
/// The three columns are the same fact told by three kinds of storage: a local
|
||||||
|
/// directory proves it is unchanged with its mtime and entry count, and a
|
||||||
|
/// remote one proves it with a propagating ETag (ARCH §6.6). Clearing two of
|
||||||
|
/// them and leaving the third would disarm the re-listing on exactly the
|
||||||
|
/// libraries this is most likely to be called for — a remote scan prunes on
|
||||||
|
/// the ETag alone, so a root that came back would be walked, found unchanged
|
||||||
|
/// at every level, pruned whole, and left with every row still marked offline
|
||||||
|
/// and nothing that would ever clear the mark.
|
||||||
|
///
|
||||||
|
/// # Public, because losing a root is not only the local walk's business
|
||||||
|
///
|
||||||
|
/// This began as the private end of [`scan_root`]'s root-failure branches,
|
||||||
|
/// which is the only route a library reached through [`Storage`] can take.
|
||||||
|
/// The application does not currently take that route at all: it opens
|
||||||
|
/// libraries through `dr-sync`'s connectors, so the discovery happens in a
|
||||||
|
/// crate that cannot see this one's internals, and the correct response is
|
||||||
|
/// identical (FR-PLAT-AND-2). Exported rather than reimplemented beside the
|
||||||
|
/// caller that found out — a second copy would be a second thing to remember
|
||||||
|
/// when the ETag rule below changes.
|
||||||
|
///
|
||||||
|
/// [`Storage`]: dr_plat::Storage
|
||||||
|
pub fn mark_root_offline(conn: &Connection, root: RootId) -> Result<(), CatalogError> {
|
||||||
let root_id = root.0 as i64;
|
let root_id = root.0 as i64;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE images SET availability = ?1 WHERE root_id = ?2 AND availability != ?1",
|
"UPDATE images SET availability = ?1 WHERE root_id = ?2 AND availability != ?1",
|
||||||
rusqlite::params![availability_code(Availability::Offline), root_id],
|
rusqlite::params![availability_code(Availability::Offline), root_id],
|
||||||
)?;
|
)?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE folders SET mtime = NULL, entry_count = NULL WHERE root_id = ?1",
|
"UPDATE folders SET mtime = NULL, entry_count = NULL, etag = NULL WHERE root_id = ?1",
|
||||||
[root_id],
|
[root_id],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -995,6 +1019,40 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-PLAT-AND-2 | FR-CAT-9
|
||||||
|
#[test]
|
||||||
|
fn marking_a_root_offline_forgets_the_remote_validator_too() {
|
||||||
|
// The half of the marking that only a remote library can notice, and
|
||||||
|
// the reason it has to be here rather than beside the connector: a
|
||||||
|
// remote scan prunes on the propagating ETag alone (ARCH §6.6). Clear
|
||||||
|
// the local mtime and leave the ETag standing and a library that came
|
||||||
|
// back would be walked, found unchanged at every level, pruned whole,
|
||||||
|
// and left with every row still marked offline — with nothing that
|
||||||
|
// would ever clear the mark, because clearing it is something only a
|
||||||
|
// listing can do.
|
||||||
|
//
|
||||||
|
// Written directly because this module never writes an ETag; it is
|
||||||
|
// `ui/dr-ui/src/library.rs`'s scan that does, against the same table.
|
||||||
|
let lib = Library::new("etag-forgotten");
|
||||||
|
lib.file("2026/IMG.CR3", b"raw");
|
||||||
|
lib.scan();
|
||||||
|
lib.conn()
|
||||||
|
.execute(
|
||||||
|
"UPDATE folders SET etag = 'e1' WHERE root_id = ?1",
|
||||||
|
[lib.root.0 as i64],
|
||||||
|
)
|
||||||
|
.expect("etag");
|
||||||
|
assert!(lib.count("SELECT COUNT(*) FROM folders WHERE etag IS NOT NULL") > 0);
|
||||||
|
|
||||||
|
mark_root_offline(lib.conn(), lib.root).expect("mark");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
lib.count("SELECT COUNT(*) FROM folders WHERE etag IS NOT NULL"),
|
||||||
|
0,
|
||||||
|
"an unreachable library must be re-listed, not pruned as unchanged"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_root_that_comes_back_is_available_again() {
|
fn a_root_that_comes_back_is_available_again() {
|
||||||
// The other half: a drive plugged back in must return the library to
|
// The other half: a drive plugged back in must return the library to
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-4 | FR-EXP-6 | FR-EXP-9
|
//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-4 | FR-EXP-6 | FR-EXP-9 | R3
|
||||||
//! Turning a rendered frame into a file's worth of bytes.
|
//! Turning a rendered frame into a file's worth of bytes.
|
||||||
//!
|
//!
|
||||||
//! # What this crate is, and is not
|
//! # What this crate is, and is not
|
||||||
|
|||||||
@@ -1026,6 +1026,44 @@ impl AdjustPass {
|
|||||||
h
|
h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-PLAT-AND-5 | NFR-RES-1
|
||||||
|
/// Give back every allocation this pass is holding only to be fast again.
|
||||||
|
///
|
||||||
|
/// What goes, and why each is safe to lose:
|
||||||
|
///
|
||||||
|
/// - **The compiled pipelines**, here and in the detail stage. A pure
|
||||||
|
/// lookup keyed by structure hash with a compile-on-miss behind it, and
|
||||||
|
/// unbounded until now — nothing ever removed an entry, so a session
|
||||||
|
/// that visited enough distinct edit structures accumulated shader
|
||||||
|
/// objects for the life of the process.
|
||||||
|
/// - **The detail intermediates**, which are viewport-sized `Rgba16Float`
|
||||||
|
/// and, as `detail.rs` says of them, grow but never shrink.
|
||||||
|
/// - **The two output textures.** Dropping these does not take the picture
|
||||||
|
/// off the screen: whatever was handed to the compositor holds its own
|
||||||
|
/// reference to the `wgpu::Texture`, so releasing ours only means the
|
||||||
|
/// *next* render allocates rather than reuses. `ensure_target` already
|
||||||
|
/// treats an empty slot as "allocate", because that is the state it
|
||||||
|
/// starts in.
|
||||||
|
///
|
||||||
|
/// **`colour_key` must be cleared with them, and this is the part that
|
||||||
|
/// would bite.** The key is the promise that slot 0 of the detail pool
|
||||||
|
/// still holds the fused colour result, and it is what lets a sharpening
|
||||||
|
/// slider skip the colour chain (FR-DEV-3d). Freeing the pool while the
|
||||||
|
/// promise stood would make the next detail-only render sample a
|
||||||
|
/// just-allocated texture with nothing in it — a silently wrong frame, not
|
||||||
|
/// a failure, and one that would only appear on a device under memory
|
||||||
|
/// pressure.
|
||||||
|
///
|
||||||
|
/// What deliberately stays: the demosaiced source is not this pass's to
|
||||||
|
/// drop, the film tables are set once by a caller that will not be asked
|
||||||
|
/// again, and the bind group layouts are bytes rather than megabytes.
|
||||||
|
pub fn release_caches(&mut self) {
|
||||||
|
self.cache.clear();
|
||||||
|
self.detail.release_caches();
|
||||||
|
self.targets = [None, None];
|
||||||
|
self.colour_key = None;
|
||||||
|
}
|
||||||
|
|
||||||
/// How many distinct pipelines are compiled. Exposed for tests asserting
|
/// How many distinct pipelines are compiled. Exposed for tests asserting
|
||||||
/// that slider movement does not recompile.
|
/// that slider movement does not recompile.
|
||||||
pub fn cached_pipelines(&self) -> usize {
|
pub fn cached_pipelines(&self) -> usize {
|
||||||
|
|||||||
@@ -157,6 +157,24 @@ impl Intermediates {
|
|||||||
self.allocations += 1;
|
self.allocations += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-PLAT-AND-5
|
||||||
|
/// Drop the pool, leaving it as [`Intermediates::new`] left it.
|
||||||
|
///
|
||||||
|
/// The size is reset along with the slots, not merely because it is tidy:
|
||||||
|
/// [`Self::ensure`] only refills when the count is short *or* the size
|
||||||
|
/// differs, so a pool cleared while still claiming its old dimensions is
|
||||||
|
/// indistinguishable from one that never held anything — which is fine
|
||||||
|
/// here, and would stop being fine the moment `ensure` grew a fast path
|
||||||
|
/// that trusted the stored size. `allocations` deliberately keeps
|
||||||
|
/// counting: it exists so a test can see textures being made, and a
|
||||||
|
/// counter reset on eviction would hide a reallocation storm rather than
|
||||||
|
/// report one.
|
||||||
|
fn release(&mut self) {
|
||||||
|
self.slots.clear();
|
||||||
|
self.width = 0;
|
||||||
|
self.height = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs the detail stage.
|
/// Runs the detail stage.
|
||||||
@@ -526,6 +544,19 @@ impl DetailRunner {
|
|||||||
self.cache.len()
|
self.cache.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-PLAT-AND-5
|
||||||
|
/// Give back everything this stage is only holding to be fast.
|
||||||
|
///
|
||||||
|
/// Both pools and the pipeline cache. Nothing here is state: a pool slot
|
||||||
|
/// is re-created by the next [`Intermediates::ensure`] and a pipeline by
|
||||||
|
/// the next compile-on-miss, so the only cost of this call is the work of
|
||||||
|
/// doing both again.
|
||||||
|
pub(crate) fn release_caches(&mut self) {
|
||||||
|
self.cache.clear();
|
||||||
|
self.pool.release();
|
||||||
|
self.reduced.release();
|
||||||
|
}
|
||||||
|
|
||||||
/// How many intermediate textures have been allocated since this pass was
|
/// How many intermediate textures have been allocated since this pass was
|
||||||
/// created. For tests — see [`crate::MaskPass::allocations`] for the
|
/// created. For tests — see [`crate::MaskPass::allocations`] for the
|
||||||
/// regression this shape of counter exists to catch.
|
/// regression this shape of counter exists to catch.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
|
//! TRACES: NFR-PORT-2
|
||||||
//! GPU device and compute for DarkRoom.
|
//! GPU device and compute for DarkRoom.
|
||||||
//!
|
//!
|
||||||
//! In v0.1 this exists to prove one thing: a compute shader can write a
|
//! In v0.1 this exists to prove one thing: a compute shader can write a
|
||||||
@@ -21,6 +22,7 @@ mod adjust;
|
|||||||
mod demosaic;
|
mod demosaic;
|
||||||
mod detail;
|
mod detail;
|
||||||
mod error;
|
mod error;
|
||||||
|
mod focus;
|
||||||
mod histogram;
|
mod histogram;
|
||||||
mod mask;
|
mod mask;
|
||||||
mod readback;
|
mod readback;
|
||||||
@@ -33,6 +35,7 @@ pub use adjust::AdjustPass;
|
|||||||
pub use demosaic::{DemosaicedImage, Demosaicer};
|
pub use demosaic::{DemosaicedImage, Demosaicer};
|
||||||
pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT;
|
pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT;
|
||||||
pub use error::GpuError;
|
pub use error::GpuError;
|
||||||
|
pub use focus::{FocusPeakPass, FocusPeaking, PeakColour, PeakSensitivity};
|
||||||
// Renamed on the way out: `BINS` says enough inside `histogram`, and nothing
|
// Renamed on the way out: `BINS` says enough inside `histogram`, and nothing
|
||||||
// at all at a crate root shared with demosaic and segmentation.
|
// at all at a crate root shared with demosaic and segmentation.
|
||||||
pub use histogram::{Histogram, HistogramPass, BINS as HISTOGRAM_BINS};
|
pub use histogram::{Histogram, HistogramPass, BINS as HISTOGRAM_BINS};
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// TRACES: FR-CULL-3 | NFR-P14
|
||||||
|
// Marking what is sharp, in a layer laid over the frame rather than into it.
|
||||||
|
//
|
||||||
|
// # Why the top octave, and not a gradient
|
||||||
|
//
|
||||||
|
// The obvious detector is a gradient magnitude — Sobel, or a central
|
||||||
|
// difference — and it is the wrong one, for a reason that decides whether the
|
||||||
|
// overlay is useful at all. A gradient answers "is there an edge here", and a
|
||||||
|
// defocused edge is still an edge: blur a 100-code step with a two-pixel
|
||||||
|
// Gaussian and the peak gradient is still around 20 codes per pixel, larger
|
||||||
|
// than a genuinely sharp edge across a low-contrast texture. Peaking built on
|
||||||
|
// gradients lights up the out-of-focus background of every portrait ever
|
||||||
|
// taken, which is the frame it exists to reject.
|
||||||
|
//
|
||||||
|
// What separates sharp from soft is *scale*, not amplitude. Defocus is a
|
||||||
|
// low-pass: it removes the top octave and leaves everything below it intact.
|
||||||
|
// So the detector is a high-pass — this pixel against the mean of its eight
|
||||||
|
// neighbours, a discrete Laplacian — which by construction responds only to
|
||||||
|
// the frequencies defocus destroys.
|
||||||
|
//
|
||||||
|
// The arithmetic, on a one-dimensional step of height D:
|
||||||
|
//
|
||||||
|
// | profile | abs(centre - mean of 8) |
|
||||||
|
// |--------------------------|-------------------------|
|
||||||
|
// | hard step, 1 px | 0.375 D |
|
||||||
|
// | Gaussian blur, sigma 1 | ~0.10 D |
|
||||||
|
// | Gaussian blur, sigma 2 | ~0.03 D |
|
||||||
|
// | linear ramp, any slope | 0 |
|
||||||
|
//
|
||||||
|
// The ramp row is the property being bought: the smooth luminance falloff
|
||||||
|
// across an out-of-focus highlight scores zero however bright it is.
|
||||||
|
//
|
||||||
|
// # Why luma, and why the histogram's luma
|
||||||
|
//
|
||||||
|
// One channel rather than three, because a colour edge carrying no luminance
|
||||||
|
// difference is both rare and, at the acuity an overlay is read at, invisible.
|
||||||
|
// The weights are `histogram.wgsl`'s 54/183/19 over 256 — the same Rec.709
|
||||||
|
// weighting on the same encoded values — so the two instruments in this
|
||||||
|
// application agree about what "luma" means. Two definitions of brightness in
|
||||||
|
// one panel is the kind of disagreement nobody finds until it has already
|
||||||
|
// misled someone.
|
||||||
|
//
|
||||||
|
// # Why the frame is read where it is encoded, and not in linear light
|
||||||
|
//
|
||||||
|
// This runs on the output of the display transform, on encoded values, and
|
||||||
|
// that is deliberate: a fixed difference in sRGB code values is roughly
|
||||||
|
// equally visible wherever it sits in the range, which is what a transfer
|
||||||
|
// curve is for. Measured in linear light the same detector would need a
|
||||||
|
// threshold that varied with exposure, and a shadow texture the photographer
|
||||||
|
// can plainly see would score a hundredth of the identical texture in the
|
||||||
|
// highlights. The encoding has already done the normalisation, so the
|
||||||
|
// threshold is one number.
|
||||||
|
//
|
||||||
|
// # Why this writes a layer and not the picture
|
||||||
|
//
|
||||||
|
// The frame the compositor is handed is also what the histogram counts and
|
||||||
|
// what an export renders (`app.slint`, on the region overlay: a diagnostic
|
||||||
|
// "must not reach the histogram, an export, or the texture the develop pass
|
||||||
|
// hands the compositor"). So the marks go in their own texture — transparent
|
||||||
|
// everywhere except where something is in focus — and the compositor blends
|
||||||
|
// them. Nothing about the photograph changes, and the peaking overlay cannot
|
||||||
|
// leak into a measurement or a file.
|
||||||
|
//
|
||||||
|
// Alpha is written as exactly 0 or exactly 1, never between. The importing
|
||||||
|
// compositor's convention for whether colour arrives premultiplied is not
|
||||||
|
// something this shader can see, and at those two values the two conventions
|
||||||
|
// agree — which is a cheaper guarantee than being right about which one it is.
|
||||||
|
|
||||||
|
struct Params {
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
// Luma difference at which a pixel is called in focus. See
|
||||||
|
// `PeakSensitivity::threshold` for where the three values come from.
|
||||||
|
threshold: f32,
|
||||||
|
// std140 rounds the scalar block up to 16 bytes before the vec4; named so
|
||||||
|
// the Rust struct's padding is visibly the same shape.
|
||||||
|
pad_0: u32,
|
||||||
|
// The mark's colour, fully saturated. Its alpha is ignored — see above.
|
||||||
|
marker: vec4<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(0) @binding(0) var frame: texture_2d<f32>;
|
||||||
|
@group(0) @binding(1) var<uniform> params: Params;
|
||||||
|
@group(0) @binding(2) var marks: texture_storage_2d<rgba8unorm, write>;
|
||||||
|
|
||||||
|
/// Rec.709 luma of an encoded triple, weighted exactly as `histogram.wgsl`
|
||||||
|
/// weights it. 54 + 183 + 19 is 256, so the weights sum to unity.
|
||||||
|
fn luma(c: vec3<f32>) -> f32 {
|
||||||
|
return dot(c, vec3<f32>(54.0, 183.0, 19.0) / 256.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A neighbour, with the frame edge held rather than wrapped.
|
||||||
|
///
|
||||||
|
/// Clamping duplicates the edge pixel into the missing half of the
|
||||||
|
/// neighbourhood, which pulls the mean towards the centre and so biases the
|
||||||
|
/// response *down* on the outermost row and column. That is the right
|
||||||
|
/// direction to be wrong in: the failure is a missing mark at the frame edge,
|
||||||
|
/// where nobody is judging focus, rather than a false mark produced by
|
||||||
|
/// folding the opposite side of the picture into the kernel.
|
||||||
|
fn neighbour(x: i32, y: i32) -> f32 {
|
||||||
|
let cx = clamp(x, 0i, i32(params.width) - 1i);
|
||||||
|
let cy = clamp(y, 0i, i32(params.height) - 1i);
|
||||||
|
return luma(textureLoad(frame, vec2<i32>(cx, cy), 0).rgb);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8x8, matching the detail stage's dispatch. Each texel is loaded by nine
|
||||||
|
// invocations and no workgroup-memory tile is built to avoid that: at viewport
|
||||||
|
// resolution the reads are perfectly coherent and the texture cache serves
|
||||||
|
// eight of the nine. The budget is NFR-P14's 100 ms against a dispatch
|
||||||
|
// measured in tenths of a millisecond, so there is nothing here worth the
|
||||||
|
// complexity of a tiled load.
|
||||||
|
@compute @workgroup_size(8, 8, 1)
|
||||||
|
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||||
|
if (gid.x >= params.width || gid.y >= params.height) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let x = i32(gid.x);
|
||||||
|
let y = i32(gid.y);
|
||||||
|
|
||||||
|
// The eight neighbours, centre excluded. Excluded rather than folded in
|
||||||
|
// because it makes the response readable: `abs(c - mean8)` is the height
|
||||||
|
// of this pixel above its surroundings in the same units as the step it
|
||||||
|
// sits on, so the threshold can be quoted as a luma difference rather than
|
||||||
|
// as eight-ninths of one.
|
||||||
|
var sum = 0.0;
|
||||||
|
for (var dy = -1; dy <= 1; dy = dy + 1) {
|
||||||
|
for (var dx = -1; dx <= 1; dx = dx + 1) {
|
||||||
|
if (dx != 0 || dy != 0) {
|
||||||
|
sum = sum + neighbour(x + dx, y + dy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let centre = luma(textureLoad(frame, vec2<i32>(x, y), 0).rgb);
|
||||||
|
let response = abs(centre - sum / 8.0);
|
||||||
|
|
||||||
|
if (response >= params.threshold) {
|
||||||
|
textureStore(marks, vec2<i32>(x, y), vec4<f32>(params.marker.rgb, 1.0));
|
||||||
|
} else {
|
||||||
|
textureStore(marks, vec2<i32>(x, y), vec4<f32>(0.0, 0.0, 0.0, 0.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -413,3 +413,56 @@ fn an_empty_chain_falls_through_to_the_ordinary_render() {
|
|||||||
assert_eq!(pass.detail_dispatches(), 0);
|
assert_eq!(pass.detail_dispatches(), 0);
|
||||||
assert_eq!(pass.detail_allocations(), 0);
|
assert_eq!(pass.detail_allocations(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-PLAT-AND-5
|
||||||
|
#[test]
|
||||||
|
fn eviction_gives_the_pools_back_without_changing_a_pixel() {
|
||||||
|
// The half of memory-pressure eviction that cannot be checked by looking
|
||||||
|
// at a counter. `release_caches` frees the detail pool, and slot 0 of that
|
||||||
|
// pool is where the fused colour result lives between frames — so the
|
||||||
|
// render after an eviction has to notice that the promise recorded in
|
||||||
|
// `colour_key` no longer holds and run the colour chain again.
|
||||||
|
//
|
||||||
|
// Leave the key standing and this test does not error: it draws. It draws
|
||||||
|
// whatever a freshly-allocated texture happens to contain, which is the
|
||||||
|
// failure worth building a test around, because on a device it would
|
||||||
|
// appear only under memory pressure and only as a wrong-looking photograph.
|
||||||
|
let Some(ctx) = ctx() else { return };
|
||||||
|
const SIZE: u32 = 48;
|
||||||
|
let source = step_edge(&ctx, SIZE);
|
||||||
|
let mut pass = AdjustPass::new(&ctx);
|
||||||
|
let mut graph = EditGraph::with_detail_probe();
|
||||||
|
graph.set_param(PROBE, RADIUS, 0.05);
|
||||||
|
|
||||||
|
let before = render(&ctx, &mut pass, &graph, &source, SIZE);
|
||||||
|
assert!(
|
||||||
|
pass.cached_pipelines() > 0,
|
||||||
|
"the colour pass compiled something"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
pass.cached_detail_pipelines() > 0,
|
||||||
|
"so did the detail stage"
|
||||||
|
);
|
||||||
|
let allocations = pass.detail_allocations();
|
||||||
|
assert!(allocations > 0, "and the pool holds textures");
|
||||||
|
|
||||||
|
pass.release_caches();
|
||||||
|
assert_eq!(pass.cached_pipelines(), 0);
|
||||||
|
assert_eq!(pass.cached_detail_pipelines(), 0);
|
||||||
|
|
||||||
|
// The same edit at the same size. Nothing about the picture changed, so
|
||||||
|
// nothing about the pixels may change either — only what it cost.
|
||||||
|
let after = render(&ctx, &mut pass, &graph, &source, SIZE);
|
||||||
|
assert_eq!(before.len(), after.len());
|
||||||
|
for (i, (a, b)) in before.iter().zip(&after).enumerate() {
|
||||||
|
assert!(
|
||||||
|
a.abs_diff(*b) <= 1,
|
||||||
|
"byte {i}: {a} before eviction, {b} after — the colour chain did \
|
||||||
|
not re-run, so this frame is reading an empty intermediate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
pass.detail_allocations() > allocations,
|
||||||
|
"the pool was rebuilt, which is the evidence it was really given back"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
//! TRACES: FR-DEV-1
|
||||||
//! The edit graph — an ordered set of operations (ARCH §3.4).
|
//! The edit graph — an ordered set of operations (ARCH §3.4).
|
||||||
//!
|
//!
|
||||||
//! CPU-side state, deliberately. The GPU device can be lost and rebuilt at any
|
//! CPU-side state, deliberately. The GPU device can be lost and rebuilt at any
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
//! TRACES: R3
|
||||||
//! The develop pipeline — operations, descriptors, and shader composition.
|
//! The develop pipeline — operations, descriptors, and shader composition.
|
||||||
//!
|
//!
|
||||||
//! # What this crate is
|
//! # What this crate is
|
||||||
@@ -62,7 +63,7 @@ pub use operation::{
|
|||||||
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
|
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
|
||||||
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, RESERVED_UNIFORM_FIELDS,
|
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, RESERVED_UNIFORM_FIELDS,
|
||||||
};
|
};
|
||||||
pub use preset::{Preset, Scope};
|
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Scope};
|
||||||
pub use sidecar::{Sidecar, Version};
|
pub use sidecar::{Sidecar, Version};
|
||||||
pub use spot::{Spot, SpotMode, SpotSet};
|
pub use spot::{Spot, SpotMode, SpotSet};
|
||||||
pub use state::{EditState, FilmRebake, FilmRef};
|
pub use state::{EditState, FilmRebake, FilmRef};
|
||||||
|
|||||||
@@ -39,6 +39,8 @@
|
|||||||
//! is the whole claim the action makes.
|
//! is the whole claim the action makes.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt;
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
use crate::descriptor::{OpId, ParamId};
|
use crate::descriptor::{OpId, ParamId};
|
||||||
use crate::graph::EditGraph;
|
use crate::graph::EditGraph;
|
||||||
@@ -240,6 +242,328 @@ pub(crate) fn resolve(graph: &EditGraph, op: &str, param: &str) -> Option<(OpId,
|
|||||||
Some((cap.id, p.id))
|
Some((cap.id, p.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Named presets
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
/// Format version of a preset library file.
|
||||||
|
///
|
||||||
|
/// Present where `settings.json` has no version field, and the difference is
|
||||||
|
/// not an inconsistency. Settings are a flat bag of `#[serde(default)]`
|
||||||
|
/// fields, so an older file is *missing* keys rather than wrong about them and
|
||||||
|
/// additive change needs no version. This file has structure — blocks, and a
|
||||||
|
/// name carried in a block header — and a change to what a block *means* is
|
||||||
|
/// not something a reader can detect by noticing an absent key.
|
||||||
|
pub const LIBRARY_FORMAT_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// The file extension for a DarkRoom preset library.
|
||||||
|
pub const LIBRARY_EXTENSION: &str = "drpl";
|
||||||
|
|
||||||
|
/// Why a name was refused.
|
||||||
|
///
|
||||||
|
/// A closed set rather than a string, so the interface can say something
|
||||||
|
/// specific about each and the message is not written here — this crate
|
||||||
|
/// depends on nothing and has no business holding user-facing prose.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum NameError {
|
||||||
|
/// Empty, or nothing but whitespace.
|
||||||
|
Empty,
|
||||||
|
/// Contains a character the block header cannot carry: `[`, `]`, or a
|
||||||
|
/// line break.
|
||||||
|
///
|
||||||
|
/// A round-trip constraint rather than a matter of taste. The header is
|
||||||
|
/// `[preset <name>]`, so a `]` inside the name would make the file parse
|
||||||
|
/// back as a *different* library, and a newline would make it parse back
|
||||||
|
/// as two.
|
||||||
|
Unrepresentable,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
/// A set of named presets, as stored.
|
||||||
|
///
|
||||||
|
/// # Why one file rather than one file per preset
|
||||||
|
///
|
||||||
|
/// A preset per file makes the name a *path*, and every name then has to
|
||||||
|
/// survive a filesystem: a `/` becomes a directory, a name that differs only
|
||||||
|
/// in case collides on one platform and not another, and renaming becomes two
|
||||||
|
/// operations that can half-fail. Here the name is a key in a document, so
|
||||||
|
/// renaming is a map operation, deleting cannot leave an orphan, and the whole
|
||||||
|
/// library is written atomically by the same tmp-and-rename the settings store
|
||||||
|
/// uses.
|
||||||
|
///
|
||||||
|
/// The cost is that the file is rewritten whole on every change. A preset is a
|
||||||
|
/// few dozen floats and a photographer has tens of them, not thousands, so the
|
||||||
|
/// file is kilobytes; the trade would look different at a scale this is not.
|
||||||
|
///
|
||||||
|
/// # Why the sidecar's shape rather than JSON
|
||||||
|
///
|
||||||
|
/// A preset *is* the non-default half of a version (see the module note), so
|
||||||
|
/// the lines here are the lines a sidecar carries, keyed the same way. That
|
||||||
|
/// makes the two files diffable against each other and lets someone debugging
|
||||||
|
/// an edit paste a block from one into the other. It also keeps this crate
|
||||||
|
/// dependency-free, which is the property that lets it be tested without a
|
||||||
|
/// device (ARCH §6.5a).
|
||||||
|
///
|
||||||
|
/// # Ordering
|
||||||
|
///
|
||||||
|
/// By name, so the same library always writes the same bytes and a caller may
|
||||||
|
/// compare content to decide whether a write is needed — the same
|
||||||
|
/// determinism [`Sidecar::to_text`](crate::Sidecar::to_text) offers, for the
|
||||||
|
/// same reason.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
|
pub struct PresetLibrary {
|
||||||
|
presets: BTreeMap<String, Preset>,
|
||||||
|
/// Lines inside a `[preset]` block that were not `op.param = float`.
|
||||||
|
///
|
||||||
|
/// Keyed by preset name and written back verbatim, so a build that
|
||||||
|
/// predates whatever wrote them round-trips the file without discarding
|
||||||
|
/// it. Unknown *parameters* need no such machinery: [`Preset`] holds
|
||||||
|
/// whatever keys it was given and resolves them against the descriptors
|
||||||
|
/// only at apply time, so a parameter this build has never heard of
|
||||||
|
/// survives simply by being stored.
|
||||||
|
unknown: BTreeMap<String, Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PresetLibrary {
|
||||||
|
/// Whether a name is storable, and why not if it is not.
|
||||||
|
///
|
||||||
|
/// Leading and trailing whitespace is trimmed rather than refused — it is
|
||||||
|
/// almost always a stray keystroke, and refusing it would mean a dialogue
|
||||||
|
/// about a space.
|
||||||
|
pub fn check_name(name: &str) -> Result<String, NameError> {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(NameError::Empty);
|
||||||
|
}
|
||||||
|
if name.contains([']', '[', '\n', '\r']) {
|
||||||
|
return Err(NameError::Unrepresentable);
|
||||||
|
}
|
||||||
|
Ok(name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store `preset` under `name`, replacing any preset already there.
|
||||||
|
///
|
||||||
|
/// Returns whether something was replaced.
|
||||||
|
///
|
||||||
|
/// Replacing rather than refusing, with [`Self::contains`] beside it for a
|
||||||
|
/// caller that wants to ask first: whether overwriting needs a
|
||||||
|
/// confirmation is a question about the interface, and answering it here
|
||||||
|
/// would force every caller into the same answer.
|
||||||
|
///
|
||||||
|
/// An *empty* preset is stored like any other. A neutral edit is a real
|
||||||
|
/// thing to save — applying it returns an image to default, which is the
|
||||||
|
/// fastest "undo everything on these forty frames" there is — and the
|
||||||
|
/// module note above is the same argument made about the clipboard.
|
||||||
|
pub fn insert(&mut self, name: &str, preset: Preset) -> Result<bool, NameError> {
|
||||||
|
let name = Self::check_name(name)?;
|
||||||
|
let replaced = self.presets.insert(name, preset).is_some();
|
||||||
|
Ok(replaced)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The preset stored under `name`.
|
||||||
|
pub fn get(&self, name: &str) -> Option<&Preset> {
|
||||||
|
self.presets.get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a preset is stored under `name`.
|
||||||
|
pub fn contains(&self, name: &str) -> bool {
|
||||||
|
self.presets.contains_key(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the preset stored under `name`, reporting whether there was one.
|
||||||
|
pub fn remove(&mut self, name: &str) -> bool {
|
||||||
|
self.unknown.remove(name);
|
||||||
|
self.presets.remove(name).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename `from` to `to`.
|
||||||
|
///
|
||||||
|
/// `Ok(false)` means there was nothing called `from` — not an error, since
|
||||||
|
/// the caller may be acting on a list another window has already changed.
|
||||||
|
/// Renaming onto an existing name replaces it, for the same reason
|
||||||
|
/// [`Self::insert`] does.
|
||||||
|
pub fn rename(&mut self, from: &str, to: &str) -> Result<bool, NameError> {
|
||||||
|
let to = Self::check_name(to)?;
|
||||||
|
let Some(preset) = self.presets.remove(from) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
if let Some(unknown) = self.unknown.remove(from) {
|
||||||
|
self.unknown.insert(to.clone(), unknown);
|
||||||
|
}
|
||||||
|
self.presets.insert(to, preset);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every stored name, in the order they are written.
|
||||||
|
pub fn names(&self) -> impl Iterator<Item = &str> {
|
||||||
|
self.presets.keys().map(String::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every stored preset with its name, in the order they are written.
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = (&str, &Preset)> {
|
||||||
|
self.presets.iter().map(|(n, p)| (n.as_str(), p))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many presets are stored.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.presets.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether nothing is stored.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.presets.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialise to the on-disk form.
|
||||||
|
///
|
||||||
|
/// Deterministic, like the sidecar's: the same library always produces the
|
||||||
|
/// same bytes.
|
||||||
|
pub fn to_text(&self) -> String {
|
||||||
|
let mut out = format!("drpl {LIBRARY_FORMAT_VERSION}\n");
|
||||||
|
for (name, preset) in &self.presets {
|
||||||
|
let _ = write!(out, "\n[preset {name}]\n");
|
||||||
|
for ((op, param), value) in preset.params() {
|
||||||
|
let _ = writeln!(out, "{op}.{param} = {}", format_value(*value));
|
||||||
|
}
|
||||||
|
for line in self.unknown.get(name).into_iter().flatten() {
|
||||||
|
let _ = writeln!(out, "{line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the on-disk form.
|
||||||
|
///
|
||||||
|
/// Tolerant on the same terms as the sidecar's parser, and for a weaker
|
||||||
|
/// version of the same reason: a preset library is not the authoritative
|
||||||
|
/// store an edit lives in, but it is still work the user did by hand, and
|
||||||
|
/// one bad line must cost that line rather than the collection. The only
|
||||||
|
/// hard failures are a file that is not a preset library at all and one
|
||||||
|
/// written by a newer build, where continuing would mean guessing.
|
||||||
|
pub fn parse(text: &str) -> Result<Self, LibraryParseError> {
|
||||||
|
let mut lines = text.lines();
|
||||||
|
let header = lines.next().unwrap_or_default().trim();
|
||||||
|
let Some(version) = header.strip_prefix("drpl ") else {
|
||||||
|
return Err(LibraryParseError::NotALibrary);
|
||||||
|
};
|
||||||
|
match version.trim().parse::<u32>() {
|
||||||
|
Ok(v) if v <= LIBRARY_FORMAT_VERSION => {}
|
||||||
|
Ok(v) => return Err(LibraryParseError::UnsupportedVersion(v)),
|
||||||
|
Err(_) => return Err(LibraryParseError::NotALibrary),
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut library = Self::default();
|
||||||
|
let mut current: Option<String> = None;
|
||||||
|
let mut params: BTreeMap<(String, String), f32> = BTreeMap::new();
|
||||||
|
|
||||||
|
for line in lines {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(head) = line
|
||||||
|
.strip_prefix("[preset ")
|
||||||
|
.and_then(|l| l.strip_suffix(']'))
|
||||||
|
{
|
||||||
|
if let Some(name) = current.take() {
|
||||||
|
library.presets.insert(name, Preset::from_params(params));
|
||||||
|
params = BTreeMap::new();
|
||||||
|
}
|
||||||
|
// A name the writer should never have produced is dropped
|
||||||
|
// rather than taken: accepting it would mean writing a file
|
||||||
|
// back out that no longer parses as this one.
|
||||||
|
match Self::check_name(head) {
|
||||||
|
Ok(name) => current = Some(name),
|
||||||
|
Err(_) => {
|
||||||
|
log::warn!("preset library: unusable preset name {head:?}; skipping");
|
||||||
|
current = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(name) = current.clone() else {
|
||||||
|
log::warn!("preset library: line outside any preset: {line}");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
match line.split_once('=') {
|
||||||
|
Some((key, value)) => {
|
||||||
|
let key = key.trim();
|
||||||
|
let value = value.trim();
|
||||||
|
match (key.split_once('.'), value.parse::<f32>()) {
|
||||||
|
(Some((op, param)), Ok(v)) if !op.is_empty() && !param.is_empty() => {
|
||||||
|
params.insert((op.to_string(), param.to_string()), v);
|
||||||
|
}
|
||||||
|
_ => library
|
||||||
|
.unknown
|
||||||
|
.entry(name)
|
||||||
|
.or_default()
|
||||||
|
.push(line.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => library
|
||||||
|
.unknown
|
||||||
|
.entry(name)
|
||||||
|
.or_default()
|
||||||
|
.push(line.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(name) = current {
|
||||||
|
library.presets.insert(name, Preset::from_params(params));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A block whose every line was unreadable still produced a preset, and
|
||||||
|
// an unknown block belonging to no preset would be written back into
|
||||||
|
// whichever one happened to sort first. Drop the orphans.
|
||||||
|
library
|
||||||
|
.unknown
|
||||||
|
.retain(|k, _| library.presets.contains_key(k));
|
||||||
|
|
||||||
|
Ok(library)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a value the way the sidecar does — no trailing `.0`, no exponent —
|
||||||
|
/// so the two files stay comparable line for line.
|
||||||
|
fn format_value(v: f32) -> String {
|
||||||
|
let mut s = format!("{v:.6}");
|
||||||
|
if s.contains('.') {
|
||||||
|
s = s.trim_end_matches('0').trim_end_matches('.').to_string();
|
||||||
|
}
|
||||||
|
if s == "-0" {
|
||||||
|
s = "0".to_string();
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum LibraryParseError {
|
||||||
|
/// The header line was missing or not a `drpl` header.
|
||||||
|
NotALibrary,
|
||||||
|
/// Written by a newer build, in a format this one cannot read.
|
||||||
|
UnsupportedVersion(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for LibraryParseError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::NotALibrary => f.write_str("not a DarkRoom preset library"),
|
||||||
|
Self::UnsupportedVersion(v) => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"preset library format version {v} is newer than this build"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for LibraryParseError {}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -563,4 +887,196 @@ mod tests {
|
|||||||
.collect();
|
.collect();
|
||||||
assert_eq!(excluded, vec![framing::ID.0]);
|
assert_eq!(excluded, vec![framing::ID.0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Named presets
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn named() -> PresetLibrary {
|
||||||
|
let mut lib = PresetLibrary::default();
|
||||||
|
lib.insert("Warm portrait", Preset::capture(&edited()))
|
||||||
|
.unwrap();
|
||||||
|
lib.insert("Neutral", Preset::default()).unwrap();
|
||||||
|
lib
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_library_round_trips_through_its_text_form() {
|
||||||
|
let lib = named();
|
||||||
|
let back = PresetLibrary::parse(&lib.to_text()).unwrap();
|
||||||
|
assert_eq!(back, lib);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_same_library_always_writes_the_same_bytes() {
|
||||||
|
// What lets a caller skip a write by comparing content. Built in the
|
||||||
|
// opposite order to `named()` so insertion order cannot be what makes
|
||||||
|
// this pass.
|
||||||
|
let mut other = PresetLibrary::default();
|
||||||
|
other.insert("Neutral", Preset::default()).unwrap();
|
||||||
|
other
|
||||||
|
.insert("Warm portrait", Preset::capture(&edited()))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(other.to_text(), named().to_text());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_neutral_preset_is_storable_and_survives_the_round_trip() {
|
||||||
|
// The empty preset is the "clear these forty frames" action, so it has
|
||||||
|
// to be a real entry rather than an absence — and a block with no
|
||||||
|
// lines under it has to parse back as a preset rather than vanish.
|
||||||
|
let back = PresetLibrary::parse(&named().to_text()).unwrap();
|
||||||
|
assert_eq!(back.get("Neutral"), Some(&Preset::default()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_line_costs_that_line_and_not_the_library() {
|
||||||
|
let text = format!(
|
||||||
|
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset Keep]\nexposure.exposure = 0.5\n\
|
||||||
|
this line is not a setting\nsaturation.amount = not a number\n"
|
||||||
|
);
|
||||||
|
let lib = PresetLibrary::parse(&text).unwrap();
|
||||||
|
let preset = lib.get("Keep").expect("the preset survived");
|
||||||
|
assert_eq!(
|
||||||
|
preset.params().get(&("exposure".into(), "exposure".into())),
|
||||||
|
Some(&0.5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lines_this_build_cannot_read_are_written_back_untouched() {
|
||||||
|
// The sidecar's version-skew promise, applied here: a build running
|
||||||
|
// behind must not silently strip what a newer one wrote.
|
||||||
|
let text = format!(
|
||||||
|
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset Keep]\nexposure.exposure = 0.5\n\
|
||||||
|
something_new_entirely\n"
|
||||||
|
);
|
||||||
|
let out = PresetLibrary::parse(&text).unwrap().to_text();
|
||||||
|
assert!(out.contains("something_new_entirely"), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_parameter_survives_without_any_machinery_for_it() {
|
||||||
|
// `Preset` stores whatever keys it is given and resolves them against
|
||||||
|
// the descriptors only at apply time, so a parameter from a newer
|
||||||
|
// build needs no preservation path of its own.
|
||||||
|
let text = format!(
|
||||||
|
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset Keep]\nnot_an_op.not_a_param = 0.25\n"
|
||||||
|
);
|
||||||
|
let out = PresetLibrary::parse(&text).unwrap().to_text();
|
||||||
|
assert!(out.contains("not_an_op.not_a_param = 0.25"), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_from_a_newer_build_is_refused_rather_than_guessed_at() {
|
||||||
|
let text = format!("drpl {}\n", LIBRARY_FORMAT_VERSION + 1);
|
||||||
|
assert_eq!(
|
||||||
|
PresetLibrary::parse(&text),
|
||||||
|
Err(LibraryParseError::UnsupportedVersion(
|
||||||
|
LIBRARY_FORMAT_VERSION + 1
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn something_that_is_not_a_preset_library_is_refused() {
|
||||||
|
assert_eq!(
|
||||||
|
PresetLibrary::parse("drsc 1\n\n[version abc]\n"),
|
||||||
|
Err(LibraryParseError::NotALibrary)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
PresetLibrary::parse(""),
|
||||||
|
Err(LibraryParseError::NotALibrary)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_name_that_would_not_parse_back_is_refused() {
|
||||||
|
// Round-trip safety, not taste: a `]` would close the header early and
|
||||||
|
// the file would read back as a different library.
|
||||||
|
let mut lib = PresetLibrary::default();
|
||||||
|
assert_eq!(
|
||||||
|
lib.insert("bracket] inside", Preset::default()),
|
||||||
|
Err(NameError::Unrepresentable)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lib.insert("two\nlines", Preset::default()),
|
||||||
|
Err(NameError::Unrepresentable)
|
||||||
|
);
|
||||||
|
assert_eq!(lib.insert(" ", Preset::default()), Err(NameError::Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn surrounding_whitespace_is_trimmed_rather_than_refused() {
|
||||||
|
let mut lib = PresetLibrary::default();
|
||||||
|
lib.insert(" Warm ", Preset::default()).unwrap();
|
||||||
|
assert!(lib.contains("Warm"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn saving_over_a_name_replaces_it_and_says_so() {
|
||||||
|
let mut lib = named();
|
||||||
|
assert_eq!(lib.insert("Warm portrait", Preset::default()), Ok(true));
|
||||||
|
assert_eq!(lib.insert("Brand new", Preset::default()), Ok(false));
|
||||||
|
assert_eq!(lib.get("Warm portrait"), Some(&Preset::default()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renaming_moves_the_preset_and_leaves_nothing_behind() {
|
||||||
|
let mut lib = named();
|
||||||
|
let before = lib.get("Warm portrait").cloned().unwrap();
|
||||||
|
assert_eq!(lib.rename("Warm portrait", "Cool portrait"), Ok(true));
|
||||||
|
assert!(!lib.contains("Warm portrait"));
|
||||||
|
assert_eq!(lib.get("Cool portrait"), Some(&before));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renaming_something_that_is_gone_is_not_an_error() {
|
||||||
|
// Another window may have deleted it since this list was drawn.
|
||||||
|
let mut lib = named();
|
||||||
|
assert_eq!(lib.rename("Never existed", "Whatever"), Ok(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_reports_whether_there_was_anything_to_delete() {
|
||||||
|
let mut lib = named();
|
||||||
|
assert!(lib.remove("Neutral"));
|
||||||
|
assert!(!lib.remove("Neutral"));
|
||||||
|
assert_eq!(lib.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stored_preset_applies_exactly_as_a_pasted_one_does() {
|
||||||
|
// The whole point of sharing one representation: a named preset is not
|
||||||
|
// a second kind of thing with a second apply path.
|
||||||
|
let lib = named();
|
||||||
|
let stored = PresetLibrary::parse(&lib.to_text()).unwrap();
|
||||||
|
let preset = stored.get("Warm portrait").unwrap();
|
||||||
|
|
||||||
|
let mut target = EditGraph::default_chain();
|
||||||
|
preset.apply(&mut target, Scope::Adjustments);
|
||||||
|
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.75));
|
||||||
|
// The target keeps its own framing on the default scope.
|
||||||
|
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stored_preset_amends_a_sidecar_without_a_graph() {
|
||||||
|
// The batch path: applying to forty images must not build forty
|
||||||
|
// graphs, so this is the call the library's batch apply makes.
|
||||||
|
let lib = named();
|
||||||
|
let preset = lib.get("Warm portrait").unwrap();
|
||||||
|
let mut params: BTreeMap<(String, String), f32> = BTreeMap::new();
|
||||||
|
params.insert(("framing".into(), "angle".into()), 5.0);
|
||||||
|
params.insert(("exposure".into(), "exposure".into()), -1.0);
|
||||||
|
|
||||||
|
preset.amend(&mut params, Scope::Adjustments);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
params.get(&("exposure".into(), "exposure".into())),
|
||||||
|
Some(&0.75)
|
||||||
|
);
|
||||||
|
// Out of scope, so the target's own crop is untouched.
|
||||||
|
assert_eq!(params.get(&("framing".into(), "angle".into())), Some(&5.0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
//! TRACES: FR-DEV-1
|
||||||
//! Sidecar serialisation — the edit graph as durable, mergeable data.
|
//! Sidecar serialisation — the edit graph as durable, mergeable data.
|
||||||
//!
|
//!
|
||||||
//! # Generic, for the same reason the UI is generic
|
//! # Generic, for the same reason the UI is generic
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// TRACES: FR-NC-6c | FR-NC-6a
|
// TRACES: FR-NC-6c | FR-NC-6a | FR-NC-6d
|
||||||
//! Hydrating a file for as long as it is needed, and no longer.
|
//! Hydrating a file for as long as it is needed, and no longer.
|
||||||
//!
|
//!
|
||||||
//! A pass over a library — thumbnails, face indexing — needs each photograph's
|
//! A pass over a library — thumbnails, face indexing — needs each photograph's
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// TRACES: FR-NC-13 | FR-NC-12
|
// TRACES: FR-NC-13 | FR-NC-12 | FR-NC-6d
|
||||||
//! A library that is just a directory.
|
//! A library that is just a directory.
|
||||||
//!
|
//!
|
||||||
//! The second [`RemoteBackend`], and the one that exists to prove the first
|
//! The second [`RemoteBackend`], and the one that exists to prove the first
|
||||||
@@ -216,10 +216,17 @@ impl std::fmt::Debug for FolderBackend {
|
|||||||
impl FolderBackend {
|
impl FolderBackend {
|
||||||
/// Open the folder at `root`.
|
/// Open the folder at `root`.
|
||||||
///
|
///
|
||||||
/// The directory must exist now. It may stop existing later — a drive
|
/// The directory must exist now, and not existing is
|
||||||
/// unplugged, a mount dropped — and that surfaces per-operation as
|
/// [`RemoteError::RootUnavailable`] — the library folder could not be
|
||||||
/// [`RemoteError::Network`], which is what puts the app into offline mode
|
/// opened, which is the whole of what this knows. A drive unplugged
|
||||||
/// and leaves the catalog readable, exactly as a dead server does.
|
/// between sessions and a path typed wrongly at setup are the same
|
||||||
|
/// observation from here, and both are answered the same way: keep the
|
||||||
|
/// catalog, say which folder, and offer it again (FR-PLAT-AND-2).
|
||||||
|
///
|
||||||
|
/// A mount dropped *during* a session surfaces per-operation as
|
||||||
|
/// [`RemoteError::Network`] instead, which is what puts the app into
|
||||||
|
/// offline mode and leaves the catalog readable, exactly as a dead server
|
||||||
|
/// does.
|
||||||
pub fn new(root: impl Into<PathBuf>) -> Result<Self, RemoteError> {
|
pub fn new(root: impl Into<PathBuf>) -> Result<Self, RemoteError> {
|
||||||
Self::with_vfs(root, Arc::new(NoVfs))
|
Self::with_vfs(root, Arc::new(NoVfs))
|
||||||
}
|
}
|
||||||
@@ -232,7 +239,15 @@ impl FolderBackend {
|
|||||||
pub fn with_vfs(root: impl Into<PathBuf>, vfs: Arc<dyn Vfs>) -> Result<Self, RemoteError> {
|
pub fn with_vfs(root: impl Into<PathBuf>, vfs: Arc<dyn Vfs>) -> Result<Self, RemoteError> {
|
||||||
let root = root.into();
|
let root = root.into();
|
||||||
if !root.is_dir() {
|
if !root.is_dir() {
|
||||||
return Err(RemoteError::Configuration(format!(
|
// TRACES: FR-PLAT-AND-2
|
||||||
|
// Not `Configuration`, which is where this lived while there was
|
||||||
|
// nothing better. The distinction that matters is not "was the
|
||||||
|
// account written wrongly" — which nothing here can know — but
|
||||||
|
// "can this library be opened", and a caller that knows the
|
||||||
|
// library was working yesterday can act on the second answer:
|
||||||
|
// mark what it holds as offline rather than deleting it, and ask
|
||||||
|
// for the folder again (FR-CAT-9).
|
||||||
|
return Err(RemoteError::RootUnavailable(format!(
|
||||||
"{} is not a folder",
|
"{} is not a folder",
|
||||||
root.display()
|
root.display()
|
||||||
)));
|
)));
|
||||||
|
|||||||
@@ -48,13 +48,22 @@ fn names(entries: &[RemoteEntry]) -> Vec<String> {
|
|||||||
|
|
||||||
// --- opening --------------------------------------------------------------
|
// --- opening --------------------------------------------------------------
|
||||||
|
|
||||||
|
/// TRACES: FR-PLAT-AND-2
|
||||||
#[test]
|
#[test]
|
||||||
fn a_missing_folder_is_a_configuration_error_not_a_network_one() {
|
fn a_missing_folder_is_an_unavailable_root_not_a_network_failure() {
|
||||||
// It must not put the app into offline mode: nothing was unreachable, the
|
// Still not offline mode — nothing was unreachable over a wire, and
|
||||||
// account names somewhere that is not a folder.
|
// reporting it as a network failure would tell the user to wait for a
|
||||||
|
// connection that is working.
|
||||||
|
//
|
||||||
|
// `RootUnavailable` rather than `Configuration`, because the caller that
|
||||||
|
// has to act on this is the one whose library worked yesterday: an
|
||||||
|
// ejected card is indistinguishable from a mistyped path here, and only
|
||||||
|
// the first of those has a catalog full of ratings to protect.
|
||||||
let err = FolderBackend::new("/definitely/not/here").unwrap_err();
|
let err = FolderBackend::new("/definitely/not/here").unwrap_err();
|
||||||
assert!(matches!(err, RemoteError::Configuration(_)), "{err:?}");
|
assert!(matches!(err, RemoteError::RootUnavailable(_)), "{err:?}");
|
||||||
|
assert!(err.indicates_lost_root());
|
||||||
assert!(!err.indicates_offline());
|
assert!(!err.indicates_offline());
|
||||||
|
assert!(err.to_string().contains("/definitely/not/here"), "{err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- listing --------------------------------------------------------------
|
// --- listing --------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// TRACES: FR-NC-6c
|
// TRACES: FR-NC-6c | FR-NC-6d
|
||||||
//! Virtual-filesystem conventions layered over a directory.
|
//! Virtual-filesystem conventions layered over a directory.
|
||||||
//!
|
//!
|
||||||
//! A sync client in virtual-files mode leaves a *placeholder* where a file is
|
//! A sync client in virtual-files mode leaves a *placeholder* where a file is
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
//! TRACES: R6 | NFR-SEC-3
|
||||||
//! Nextcloud connector.
|
//! Nextcloud connector.
|
||||||
//!
|
//!
|
||||||
//! One of two [`RemoteBackend`] implementations, registered through
|
//! One of two [`RemoteBackend`] implementations, registered through
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// TRACES: FR-NC-12 | FR-NC-1
|
// TRACES: FR-NC-12 | FR-NC-1 | NFR-SEC-3
|
||||||
//! Registering Nextcloud as a storage backend.
|
//! Registering Nextcloud as a storage backend.
|
||||||
//!
|
//!
|
||||||
//! The account model this connector used to own now lives in
|
//! The account model this connector used to own now lives in
|
||||||
|
|||||||
@@ -61,14 +61,18 @@ pub enum RemoteError {
|
|||||||
/// connector for.
|
/// connector for.
|
||||||
///
|
///
|
||||||
/// **Not a network failure and not an auth failure**, which is why it is
|
/// **Not a network failure and not an auth failure**, which is why it is
|
||||||
/// its own variant. A folder library whose directory has been unmounted,
|
/// its own variant. An account naming a backend a cut-down build was not
|
||||||
/// or an account naming a backend a cut-down build was not compiled with,
|
/// compiled with, or a path that would leave the library folder, produces
|
||||||
/// produces a request that never leaves the process — reporting either as
|
/// a request that never leaves the process — reporting either as `Network`
|
||||||
/// `Network` would put the app into offline mode and tell the user their
|
/// would put the app into offline mode and tell the user their connection
|
||||||
/// connection is down, and reporting them as `AuthFailed` would send them
|
/// is down, and reporting them as `AuthFailed` would send them to re-enter
|
||||||
/// to re-enter a credential that is fine. The message names what is wrong
|
/// a credential that is fine. The message names what is wrong with the
|
||||||
/// with the configuration, because that is the only thing that will fix
|
/// configuration, because that is the only thing that will fix it.
|
||||||
/// it.
|
///
|
||||||
|
/// A folder library whose directory is not there was once reported here
|
||||||
|
/// too, and is now [`RootUnavailable`](Self::RootUnavailable): it is not
|
||||||
|
/// something wrong with the configuration, it is the library being gone,
|
||||||
|
/// and only the second of those has a catalog to protect.
|
||||||
#[error("account misconfigured: {0}")]
|
#[error("account misconfigured: {0}")]
|
||||||
Configuration(String),
|
Configuration(String),
|
||||||
|
|
||||||
@@ -105,6 +109,43 @@ pub enum RemoteError {
|
|||||||
|
|
||||||
#[error("operation cancelled")]
|
#[error("operation cancelled")]
|
||||||
Cancelled,
|
Cancelled,
|
||||||
|
|
||||||
|
/// TRACES: FR-PLAT-AND-2 | FR-CAT-9
|
||||||
|
/// The library root itself could not be opened.
|
||||||
|
///
|
||||||
|
/// **The one failure that is about the library rather than about a file in
|
||||||
|
/// it**, and it is a separate variant because every other classification
|
||||||
|
/// of it is wrong in a way that costs the user something:
|
||||||
|
///
|
||||||
|
/// - As [`NotFound`](Self::NotFound) it is indistinguishable from a folder
|
||||||
|
/// deleted between listing its parent and reaching it, which the walk
|
||||||
|
/// correctly steps over — so a whole library going away is reported as a
|
||||||
|
/// successful scan that found nothing.
|
||||||
|
/// - As [`PermissionDenied`](Self::PermissionDenied) it inherits a message
|
||||||
|
/// about Nextcloud share permissions and sidecar writes, which is
|
||||||
|
/// accurate for the case it was written for and nonsense for a tree
|
||||||
|
/// grant the user revoked in system settings.
|
||||||
|
/// - As [`Network`](Self::Network) it would claim the connection is down,
|
||||||
|
/// which is a promise that waiting will fix it.
|
||||||
|
///
|
||||||
|
/// Today this is a Nextcloud root that answers 404 or 403 — deleted, or a
|
||||||
|
/// share withdrawn — or a folder library whose directory is not there. It
|
||||||
|
/// is also, exactly, the shape a revoked Android tree permission will have
|
||||||
|
/// when the Storage Access Framework connector FR-PLAT-AND-1 asks for
|
||||||
|
/// exists: the tree URI still stored, the permission behind it gone, every
|
||||||
|
/// read failing at the root and nowhere else. **That connector is not
|
||||||
|
/// built**, so no SAF grant can be lost yet; what this variant does is put
|
||||||
|
/// the recovery FR-PLAT-AND-2 requires in the one place all three causes
|
||||||
|
/// pass through, so the third needs no new handling above it.
|
||||||
|
///
|
||||||
|
/// The response is the same for all of them and is the point of the
|
||||||
|
/// variant: mark what the catalog holds as offline, keep every row, and
|
||||||
|
/// say which library and why (FR-CAT-9).
|
||||||
|
///
|
||||||
|
/// The string is the underlying failure, not a rewrite of it. What the
|
||||||
|
/// user is told is composed where the library's name is known.
|
||||||
|
#[error("the library folder could not be opened: {0}")]
|
||||||
|
RootUnavailable(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RemoteError {
|
impl RemoteError {
|
||||||
@@ -150,6 +191,19 @@ impl RemoteError {
|
|||||||
pub fn indicates_offline(&self) -> bool {
|
pub fn indicates_offline(&self) -> bool {
|
||||||
matches!(self, RemoteError::Network(_))
|
matches!(self, RemoteError::Network(_))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-PLAT-AND-2
|
||||||
|
/// Whether the *library* is gone, as opposed to the server or one file.
|
||||||
|
///
|
||||||
|
/// Kept beside [`Self::indicates_offline`] because the two answer the same
|
||||||
|
/// shape of question and must not be confused. Both put the app into a
|
||||||
|
/// degraded mode that keeps working from the catalog, but they differ in
|
||||||
|
/// what the user is told and in what would end it: an offline library
|
||||||
|
/// comes back when the network does, and an unavailable root comes back
|
||||||
|
/// only when someone grants access again.
|
||||||
|
pub fn indicates_lost_root(&self) -> bool {
|
||||||
|
matches!(self, RemoteError::RootUnavailable(_))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -171,6 +171,37 @@ where
|
|||||||
|
|
||||||
let entries = match backend.list(&dir, None).await {
|
let entries = match backend.list(&dir, None).await {
|
||||||
Ok(e) => e,
|
Ok(e) => e,
|
||||||
|
|
||||||
|
// TRACES: FR-PLAT-AND-2 | FR-CAT-9
|
||||||
|
// The root is the one directory the walk may not step over, and
|
||||||
|
// `depth == 0` is the only place it can be — nothing is ever
|
||||||
|
// pushed at that depth but the root itself.
|
||||||
|
//
|
||||||
|
// Below, a directory that has gone is a directory that went away
|
||||||
|
// between its parent being listed and it being reached, and
|
||||||
|
// continuing is right. At the root the identical error means the
|
||||||
|
// *library* is gone, and continuing is catastrophic in a way that
|
||||||
|
// is completely silent: the walk ends, the scan succeeds having
|
||||||
|
// found nothing, and the app reports a healthy library with no new
|
||||||
|
// images while every path in the catalog now points nowhere.
|
||||||
|
//
|
||||||
|
// Refused rather than reclassified. Only these two causes are —
|
||||||
|
// a `Network` failure at the root is still a network failure, and
|
||||||
|
// must stay one or an unplugged network cable would present itself
|
||||||
|
// as a revoked permission and offline mode would never engage.
|
||||||
|
Err(RemoteError::NotFound(_)) if depth == 0 => {
|
||||||
|
return Err(RemoteError::RootUnavailable(format!(
|
||||||
|
"{root} is no longer there"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(RemoteError::PermissionDenied) if depth == 0 => {
|
||||||
|
// Deliberately not the variant's own message, which describes
|
||||||
|
// a Nextcloud share that refuses to *update* a sidecar. At the
|
||||||
|
// root nothing has been read at all.
|
||||||
|
return Err(RemoteError::RootUnavailable(format!(
|
||||||
|
"{root} can no longer be read"
|
||||||
|
)));
|
||||||
|
}
|
||||||
Err(RemoteError::NotFound(_)) => {
|
Err(RemoteError::NotFound(_)) => {
|
||||||
// Deleted between listing its parent and reaching it.
|
// Deleted between listing its parent and reaching it.
|
||||||
log::debug!("scan: {dir} vanished during the walk");
|
log::debug!("scan: {dir} vanished during the walk");
|
||||||
@@ -239,6 +270,20 @@ mod tests {
|
|||||||
caps: Capabilities,
|
caps: Capabilities,
|
||||||
lists: RefCell<usize>,
|
lists: RefCell<usize>,
|
||||||
probes: RefCell<usize>,
|
probes: RefCell<usize>,
|
||||||
|
/// Directories whose listing fails, and how.
|
||||||
|
///
|
||||||
|
/// An absent directory is not enough to model this: the fake answers
|
||||||
|
/// an unknown path with an empty listing, which is exactly the shape
|
||||||
|
/// the walk must *not* confuse with a library that has gone away.
|
||||||
|
deny: HashMap<String, Deny>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two ways a real backend refuses a directory that is still named in
|
||||||
|
/// the catalog: it is not there, or it may not be read.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum Deny {
|
||||||
|
Missing,
|
||||||
|
Forbidden,
|
||||||
}
|
}
|
||||||
|
|
||||||
// The fake is single-threaded; tests never share it across threads.
|
// The fake is single-threaded; tests never share it across threads.
|
||||||
@@ -307,8 +352,15 @@ mod tests {
|
|||||||
},
|
},
|
||||||
lists: RefCell::new(0),
|
lists: RefCell::new(0),
|
||||||
probes: RefCell::new(0),
|
probes: RefCell::new(0),
|
||||||
|
deny: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Make one directory refuse to be listed.
|
||||||
|
fn denying(mut self, path: &str, how: Deny) -> Self {
|
||||||
|
self.deny.insert(path.to_string(), how);
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -325,6 +377,11 @@ mod tests {
|
|||||||
_since: Option<&Validator>,
|
_since: Option<&Validator>,
|
||||||
) -> Result<Vec<RemoteEntry>, RemoteError> {
|
) -> Result<Vec<RemoteEntry>, RemoteError> {
|
||||||
*self.lists.borrow_mut() += 1;
|
*self.lists.borrow_mut() += 1;
|
||||||
|
match self.deny.get(dir.as_str()) {
|
||||||
|
Some(Deny::Missing) => return Err(RemoteError::NotFound(dir.to_string())),
|
||||||
|
Some(Deny::Forbidden) => return Err(RemoteError::PermissionDenied),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
Ok(self.tree.get(dir.as_str()).cloned().unwrap_or_default())
|
Ok(self.tree.get(dir.as_str()).cloned().unwrap_or_default())
|
||||||
}
|
}
|
||||||
async fn dir_validator(&self, dir: &RemotePath) -> Result<Validator, RemoteError> {
|
async fn dir_validator(&self, dir: &RemotePath) -> Result<Validator, RemoteError> {
|
||||||
@@ -404,6 +461,83 @@ mod tests {
|
|||||||
assert_eq!(r.progress.directories_listed, 3);
|
assert_eq!(r.progress.directories_listed, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-PLAT-AND-2 | FR-CAT-9
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_root_that_is_gone_is_a_failure_and_not_an_empty_library() {
|
||||||
|
// The silent one. A vanished directory below the root is stepped over,
|
||||||
|
// and before this the root was stepped over on the same terms — which
|
||||||
|
// ended the walk immediately, returned `Ok` with nothing in it, and
|
||||||
|
// let the app report a successful scan of a library that no longer
|
||||||
|
// exists. Nothing in that path is ever told the library went away, so
|
||||||
|
// nothing marks it offline and nothing tells the user.
|
||||||
|
let b =
|
||||||
|
FakeBackend::sample(ChangeDetection::PropagatingEtags).denying("Photos", Deny::Missing);
|
||||||
|
let e = scan(
|
||||||
|
&b,
|
||||||
|
&RemotePath::new("Photos"),
|
||||||
|
&FormatFilter::all(),
|
||||||
|
&HashMap::new(),
|
||||||
|
|_| {},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("a library that is not there is not a library with no photographs");
|
||||||
|
|
||||||
|
assert!(e.indicates_lost_root(), "got {e:?}");
|
||||||
|
assert!(!e.indicates_offline(), "waiting will not bring this back");
|
||||||
|
assert!(e.to_string().contains("Photos"), "names the library: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-PLAT-AND-2
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_root_that_may_not_be_read_reports_the_root_and_not_the_share_advice() {
|
||||||
|
// A Nextcloud share withdrawn, a directory the process may no longer
|
||||||
|
// read — and the shape a revoked Android tree grant will have when one
|
||||||
|
// can be held at all. Reported as plain `PermissionDenied` it would
|
||||||
|
// have carried that variant's message, which is several lines about a
|
||||||
|
// Nextcloud share refusing to *update* an existing sidecar: advice for
|
||||||
|
// a case where reads work, offered to a user whose reads have stopped
|
||||||
|
// entirely.
|
||||||
|
let b = FakeBackend::sample(ChangeDetection::PropagatingEtags)
|
||||||
|
.denying("Photos", Deny::Forbidden);
|
||||||
|
let e = scan(
|
||||||
|
&b,
|
||||||
|
&RemotePath::new("Photos"),
|
||||||
|
&FormatFilter::all(),
|
||||||
|
&HashMap::new(),
|
||||||
|
|_| {},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("a root that cannot be read is a failed scan");
|
||||||
|
|
||||||
|
assert!(e.indicates_lost_root(), "got {e:?}");
|
||||||
|
assert!(
|
||||||
|
!e.to_string().contains("sidecar"),
|
||||||
|
"the share-permission advice does not belong here: {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-PLAT-AND-2
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_folder_that_goes_away_below_the_root_is_still_stepped_over() {
|
||||||
|
// The other side of the split, and the reason the root is keyed on
|
||||||
|
// depth rather than on the error. A subfolder deleted between its
|
||||||
|
// parent being listed and it being reached is ordinary, and failing
|
||||||
|
// the scan over it would abandon every photograph beside it.
|
||||||
|
let b = FakeBackend::sample(ChangeDetection::PropagatingEtags)
|
||||||
|
.denying("Photos/2025", Deny::Missing);
|
||||||
|
let r = scan(
|
||||||
|
&b,
|
||||||
|
&RemotePath::new("Photos"),
|
||||||
|
&FormatFilter::all(),
|
||||||
|
&HashMap::new(),
|
||||||
|
|_| {},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("one folder going away is not the library going away");
|
||||||
|
|
||||||
|
assert_eq!(r.images.len(), 1, "2026 was still walked");
|
||||||
|
}
|
||||||
|
|
||||||
/// A library with a trash folder holding a soft-deleted image.
|
/// A library with a trash folder holding a soft-deleted image.
|
||||||
fn with_trash() -> FakeBackend {
|
fn with_trash() -> FakeBackend {
|
||||||
let mut b = FakeBackend::sample(ChangeDetection::PropagatingEtags);
|
let mut b = FakeBackend::sample(ChangeDetection::PropagatingEtags);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! TRACES: FR-NC-6a | FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-6 | FR-PLAT-LIN-1
|
//! TRACES: FR-NC-6a | FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-6 | FR-PLAT-LIN-1 | NFR-OPS-3
|
||||||
//! Device preferences: how much disk to spend, and what an export defaults to.
|
//! Device preferences: how much disk to spend, and what an export defaults to.
|
||||||
//!
|
//!
|
||||||
//! # Why these live beside the session and not in the catalog
|
//! # Why these live beside the session and not in the catalog
|
||||||
|
|||||||
@@ -810,6 +810,60 @@ confidence is unavailable. It does not fall back to an untuned default dressed u
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 10a. Bursts and near-duplicates
|
||||||
|
|
||||||
|
Specified by FR-CULL-5, implemented in `dr_catalog::bursts` (a v11 migration) with the pass that
|
||||||
|
feeds it in `dr_ui::bursts`.
|
||||||
|
|
||||||
|
A burst is a run of frames that are **adjacent in time and look like the frame before them**. Both
|
||||||
|
halves are load-bearing. Time alone groups a whole wedding ceremony, because a photographer working
|
||||||
|
steadily never leaves the gap that would end the run. Similarity alone groups a studio setup shot
|
||||||
|
across two days, which is a project rather than a moment. The bounds are two seconds and eight bits
|
||||||
|
of a 64-bit difference hash, and the reasoning for each figure is in the module.
|
||||||
|
|
||||||
|
**Two seconds, for a burst that fires ten frames in one.** `images.captured_at` is whole seconds:
|
||||||
|
EXIF's `DateTimeOriginal` has no sub-second field, and `SubSecTimeOriginal` is optional and widely
|
||||||
|
omitted. Ten frames of a burst therefore arrive sharing a timestamp, and any threshold finer than a
|
||||||
|
second is a threshold on information the catalog does not have. Where the pace really is faster than
|
||||||
|
two seconds, the similarity bound is what separates the frames.
|
||||||
|
|
||||||
|
**The signal is a perceptual hash of the thumbnail, not of the original.** `images.perceptual_hash`
|
||||||
|
is filled from the 256px thumbnails §7 already stores — vastly more resolution than a 9×8 reduction
|
||||||
|
uses — so a library that has been browsed has already paid for its signatures and no RAW is decoded
|
||||||
|
for this. The consequence is stated rather than hidden: an image with no thumbnail gets no
|
||||||
|
signature, and a frame with no signature never joins a burst. It is picked up by the next pass.
|
||||||
|
|
||||||
|
**It is a pass, not a job kind**, for exactly the reason §10.2 gives for face clustering: a burst is
|
||||||
|
a property of a *run* of frames and has no natural `subject_id`, so a per-image job would rebuild
|
||||||
|
the world once per photograph. It runs when the thumbnail sweep finishes, which is the first moment
|
||||||
|
the signatures can all be computed.
|
||||||
|
|
||||||
|
**A newly found burst arrives open.** The pass marks frames; it never takes them off the screen.
|
||||||
|
Collapsing on discovery would be tidier and would also mean a background pass removing photographs
|
||||||
|
from under someone part way through a cull. Folding a burst up is the user's act, it is remembered
|
||||||
|
(`burst_expanded`), and a burst that is already known keeps whatever state it is in — so the pass
|
||||||
|
that follows the next import does not spring open a morning's work.
|
||||||
|
|
||||||
|
**Nothing here ranks a frame.** The representative of a collapsed burst is its *earliest* frame,
|
||||||
|
which is a fact about the clock rather than a judgement about the photograph. FR-CULL-5 names the
|
||||||
|
failure this avoids — rejecting the only frame of an important moment because someone blinked — and
|
||||||
|
the only judgement in the subsystem is the user's own choice of representative, which lives in its
|
||||||
|
own table (`burst_pick`) so that rebuilding the grouping cannot erase it. Same argument as
|
||||||
|
`people.ignored` in §10.
|
||||||
|
|
||||||
|
**What the collapse costs the grid.** Which rows a collapsed burst hides has to be decided by the
|
||||||
|
query rather than by the cells, because the grid is a window (`LIMIT n OFFSET k`) and the frames it
|
||||||
|
hides are mostly not loaded. So the predicate joins `VISIBLE` in every query that lists or counts
|
||||||
|
cells, under the same discipline: present in four places of five, the header's count, the
|
||||||
|
scrollbar, the shift-click range and the scrub's ordinal stop describing the same list.
|
||||||
|
|
||||||
|
**What this does not settle.** Bursts are local: the tables ride along in the uploaded catalog
|
||||||
|
snapshot and nothing on the far side reads them, so a second device rebuilds its own grouping from
|
||||||
|
its own signatures. Making `burst_pick` cross-device is a merge question of the same shape as §8.4's
|
||||||
|
and is not answered here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 11. Requirements touched
|
## 11. Requirements touched
|
||||||
|
|
||||||
| ID | How this document addresses it |
|
| ID | How this document addresses it |
|
||||||
@@ -828,6 +882,7 @@ confidence is unavailable. It does not fall back to an untuned default dressed u
|
|||||||
| NFR-P1 | §3.1 one stat per directory, not per file |
|
| NFR-P1 | §3.1 one stat per directory, not per file |
|
||||||
| NFR-P3 | §7.1 on-demand generation |
|
| NFR-P3 | §7.1 on-demand generation |
|
||||||
| NFR-ARCH-2 | §6.3 priority classes shared with the GPU scheduler |
|
| NFR-ARCH-2 | §6.3 priority classes shared with the GPU scheduler |
|
||||||
|
| FR-CULL-5 | §10a burst grouping: capture-time proximity and image similarity, collapse without selection |
|
||||||
| NFR-ARCH-3 | §4.3 query cancellation, §6 job cancellation |
|
| NFR-ARCH-3 | §4.3 query cancellation, §6 job cancellation |
|
||||||
| NFR-RES-4 | §7.3 LRU cap, eviction order |
|
| NFR-RES-4 | §7.3 LRU cap, eviction order |
|
||||||
| FR-CULL-8 | §10.1 `faces` schema, §6.1 `DetectFaces` job kind on the proxy tier |
|
| FR-CULL-8 | §10.1 `faces` schema, §6.1 `DetectFaces` job kind on the proxy tier |
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# DarkRoom — Distribution
|
||||||
|
|
||||||
|
**Satisfies:** NFR-COMPAT-2 (v1 channels) · FR-PLAT-LIN-3 (sandboxed distribution)
|
||||||
|
**Companion to:** [requirements.md](requirements.md) §3.8, §4.8 · [storage.md](storage.md)
|
||||||
|
|
||||||
|
NFR-COMPAT-2 asks for the v1 channels to be *stated*, and says why in its own
|
||||||
|
second sentence: the channel decision and the storage design are coupled. A
|
||||||
|
channel is not a build target. It is a set of constraints that reach back into
|
||||||
|
the code — what the application is allowed to see, what it may ask for, and
|
||||||
|
what it must be able to do without asking. This document records which channels
|
||||||
|
v1 targets and what each one costs, and it is where to look before adding a
|
||||||
|
permission to a package rather than after.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The channels
|
||||||
|
|
||||||
|
| Platform | Channel | State | What it constrains |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Linux | Arch source package — [`packaging/PKGBUILD`](../packaging/PKGBUILD) | Built, in tree | Nothing. Full filesystem access, system Vulkan, system secret daemon |
|
||||||
|
| Linux | Flatpak — [`packaging/flatpak/`](../packaging/flatpak/) | Manifest in tree, **library selection does not work** (§4) | Portals only. No `--filesystem=`, no host mount table, no typed paths |
|
||||||
|
| Linux | AppImage | v1 channel, **recipe not yet written** (§5) | Oldest supported glibc, and no sandbox at all |
|
||||||
|
| Android | F-Droid | v1 channel, not yet submitted | GPLv3-clean build, reproducible, no proprietary blobs |
|
||||||
|
| Android | Play Store | **Not v1** (§6) | Would make ARCH §6.9 binding as policy rather than as engineering |
|
||||||
|
|
||||||
|
Three of these five exist as recipes and two do not. That is stated rather than
|
||||||
|
smoothed over, because the value of writing the channels down is knowing which
|
||||||
|
constraints are already being met and which are promises.
|
||||||
|
|
||||||
|
### What every channel has to get right
|
||||||
|
|
||||||
|
Independent of packaging format, and each of these has bitten a package
|
||||||
|
somewhere:
|
||||||
|
|
||||||
|
- **One identifier, four places.** `paris.tourolle.darkroom` is the AppStream
|
||||||
|
component id, the `.desktop` basename, the Flatpak application id, and the
|
||||||
|
string `dr_ui::run` sets as the Wayland `app_id` and X11 `WM_CLASS`. A rename
|
||||||
|
that misses one of them costs the icon in the shell or the association in the
|
||||||
|
software centre, and neither failure announces itself.
|
||||||
|
- **The metainfo, not just the desktop entry.**
|
||||||
|
[`packaging/paris.tourolle.darkroom.metainfo.xml`](../packaging/paris.tourolle.darkroom.metainfo.xml)
|
||||||
|
is the single description of the application, installed by every channel that
|
||||||
|
has somewhere to put it. Its `metadata_license` is CC0-1.0 and its
|
||||||
|
`project_license` is GPL-3.0-or-later; those differ on purpose — see the
|
||||||
|
comment in the file.
|
||||||
|
- **Vulkan is a requirement, not a preference.** The develop pipeline is
|
||||||
|
compute shaders through wgpu, and NFR-R8 — how far a CPU fallback goes — is
|
||||||
|
still open, so today there is nothing behind it. A package that installs onto
|
||||||
|
a machine with no working ICD produces an application that starts and cannot
|
||||||
|
develop.
|
||||||
|
- **A Secret Service implementation, or an honest degraded mode.** FR-NC-2 is
|
||||||
|
explicit that the absence of a secrets daemon is a stated degraded mode and
|
||||||
|
never a silent fall back to plaintext. Packages express this as an optional
|
||||||
|
dependency (the PKGBUILD) or a talk hole (the Flatpak manifest), never as a
|
||||||
|
hard dependency — a headless or minimal-WM install is a supported way to run.
|
||||||
|
- **The face models are Git LFS objects.** A checkout without `git lfs pull`
|
||||||
|
has ~130-byte pointers where 11 MB models should be. Both the PKGBUILD and
|
||||||
|
the Flatpak manifest check the file size and refuse, because the alternative
|
||||||
|
is a package whose face indexing fails inside the graph loader on a user's
|
||||||
|
machine rather than on the packager's.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Why Flatpak is the channel that matters most
|
||||||
|
|
||||||
|
Not because it is expected to be the most used. Because it is the only one that
|
||||||
|
tests anything.
|
||||||
|
|
||||||
|
The Arch package and an AppImage both hand the application the same
|
||||||
|
unrestricted process the developer runs it in, so neither can discover that a
|
||||||
|
design assumed unrestricted access. Flatpak takes that assumption away, and
|
||||||
|
FR-PLAT-LIN-3 exists to make the discovery happen deliberately rather than in a
|
||||||
|
bug report. §4 is what it discovered.
|
||||||
|
|
||||||
|
The same argument runs the other way on Android, where SAF has been the only
|
||||||
|
option since before the first line was written (ARCH §6.9) and `SourceRef`
|
||||||
|
exists because of it. Linux got the abstraction — `LocalStorage::grant` is the
|
||||||
|
one place a `Path` enters — and never got the constraint that would have proved
|
||||||
|
it worked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. What already works inside the sandbox, unchanged
|
||||||
|
|
||||||
|
Worth listing, because it is the part FR-PLAT-LIN-1 quietly paid for in
|
||||||
|
advance:
|
||||||
|
|
||||||
|
- **XDG directories.** Flatpak redirects `XDG_CONFIG_HOME`, `XDG_DATA_HOME` and
|
||||||
|
`XDG_CACHE_HOME` into `~/.var/app/paris.tourolle.darkroom/`. Settings
|
||||||
|
(`settings_store.rs`), accounts (`dr_sync::account`), the catalog and the
|
||||||
|
thumbnail store all read those variables, so every one of them lands in the
|
||||||
|
application's own directory with no code change and no permission.
|
||||||
|
- **The face models.** `system_face_models_dirs()` reads `$XDG_DATA_DIRS`
|
||||||
|
rather than hard-coding `/usr/share`, which is exactly why `/app/share`
|
||||||
|
inside a Flatpak is found by the same lookup that finds the Arch package's
|
||||||
|
copy.
|
||||||
|
- **Opening a photograph from a file manager.** The `.desktop` entry declares
|
||||||
|
the RAW MIME types and `Exec=darkroom-desktop %F`; under Flatpak the file is
|
||||||
|
exported through the document portal and arrives in `argv` as a path under
|
||||||
|
`/run/user/$UID/doc/`, which is mounted in every sandbox. `main.rs` takes
|
||||||
|
paths from `argv` and `collect()` handles a file or a directory. This is
|
||||||
|
genuine portal-mediated access and it needs nothing new.
|
||||||
|
- **The Nextcloud sign-in browser.** `open_in_browser` spawns `xdg-open`; the
|
||||||
|
freedesktop runtime's `xdg-open` forwards to the OpenURI portal, and portal
|
||||||
|
calls need no `--talk-name` because Flatpak always permits them. FR-NC-1's
|
||||||
|
"system browser, never an embedded webview" therefore holds inside the
|
||||||
|
sandbox for the same reason it holds outside it.
|
||||||
|
- **Credentials.** The keyring crate speaks the Secret Service D-Bus interface,
|
||||||
|
reached through the session-bus proxy with one talk hole. The app password
|
||||||
|
stays visible to `secret-tool` and Seahorse, which is what keeps it
|
||||||
|
individually revocable by the user.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. What does not work: choosing a library
|
||||||
|
|
||||||
|
**FR-PLAT-LIN-3 is not satisfied today, and the manifest does not pretend
|
||||||
|
otherwise.**
|
||||||
|
|
||||||
|
A folder library is chosen by typing an absolute path. `dr-sync-folder`'s
|
||||||
|
provider declares `SignIn::EndpointOnly` with the placeholder
|
||||||
|
`/home/you/Pictures`, and `normalise_endpoint` expands `~`, requires the path
|
||||||
|
to be absolute, and checks it with `std::fs`. Nothing in the tree calls the
|
||||||
|
FileChooser portal — there is no `ashpd`, no `rfd`, and no toolkit file dialog
|
||||||
|
anywhere in `ui/`, `platform/` or `core/`.
|
||||||
|
|
||||||
|
Inside a sandbox with no `--filesystem=`, `$HOME` still resolves to the real
|
||||||
|
home *path* but that directory holds only the application's own
|
||||||
|
`.var/app/…` tree. So a typed `~/Pictures` fails the `exists()` check and the
|
||||||
|
launch screen says `No folder at /home/you/Pictures.` — a truthful message
|
||||||
|
about a situation the user cannot fix from inside the application.
|
||||||
|
|
||||||
|
Import is blocked one step earlier. `dr_plat::volumes()` finds a camera card by
|
||||||
|
reading `/proc/self/mountinfo` and the `removable` flag under `/sys`. A
|
||||||
|
sandboxed process is in its own mount namespace, so the table it reads
|
||||||
|
describes the sandbox; a card mounted at `/run/media/…` on the host is not in
|
||||||
|
it. `volumes()` correctly returns an empty list, which the interface presents
|
||||||
|
as "no card found" — right for the code, wrong for the user, who is looking at
|
||||||
|
a card.
|
||||||
|
|
||||||
|
### The permission that would hide this, and why it is not in the manifest
|
||||||
|
|
||||||
|
`--filesystem=host` makes both work immediately and is the thing FR-PLAT-LIN-3
|
||||||
|
names as the alternative to portals. Granting it would mean the sandboxed build
|
||||||
|
never exercises the sandbox, which removes the entire reason for shipping one
|
||||||
|
(§2). `--filesystem=xdg-pictures` is narrower and would be tempting, but it is
|
||||||
|
still a static grant that lets a typed path resolve — it makes the same design
|
||||||
|
work by not testing it, only in a smaller directory.
|
||||||
|
|
||||||
|
So the manifest grants no filesystem access at all. The consequence is stated
|
||||||
|
plainly: **a Flatpak built from this manifest can open photographs handed to it
|
||||||
|
and cannot yet be pointed at a library.**
|
||||||
|
|
||||||
|
### What closes it
|
||||||
|
|
||||||
|
Two changes, in this order:
|
||||||
|
|
||||||
|
1. **A portal file chooser behind a platform seam.** `ashpd`'s
|
||||||
|
`OpenFileRequest` with `directory(true)` returns a URI the document portal
|
||||||
|
has exported, which the sandbox can read and which stays valid across
|
||||||
|
restarts. It resolves to a real path under `/run/user/$UID/doc/`, so
|
||||||
|
`normalise_endpoint` accepts it as it stands — `canonicalize()` on a fuse
|
||||||
|
path returns the path itself. The seam matters more than the crate: this
|
||||||
|
belongs beside `LocalStorage::grant` in `dr-plat`, which is already the one
|
||||||
|
place a `Path` enters the application, and must not become a second way for
|
||||||
|
`ui/` to learn about paths.
|
||||||
|
2. **Removable volumes through the same door.** There is no portal for "list
|
||||||
|
the mounted cards". The honest answer is that under a sandbox
|
||||||
|
`imports_supported()` should report the same `false` it reports on Android,
|
||||||
|
for the same reason it gives there — the operation cannot be performed
|
||||||
|
however hard the user tries — and the import flow should offer the folder
|
||||||
|
chooser instead of a volume list.
|
||||||
|
|
||||||
|
**Done when:** a Flatpak built from
|
||||||
|
[`packaging/flatpak/paris.tourolle.darkroom.yml`](../packaging/flatpak/paris.tourolle.darkroom.yml),
|
||||||
|
with its `finish-args` unchanged and no `flatpak override` applied, can select a
|
||||||
|
library root, scan it, and write a sidecar back into it.
|
||||||
|
|
||||||
|
### Running a Flatpak build before then
|
||||||
|
|
||||||
|
For testing the rest of the application inside the sandbox, grant the access
|
||||||
|
per-installation rather than in the manifest, so the file that describes the
|
||||||
|
application keeps telling the truth:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak override --user --filesystem=~/Pictures paris.tourolle.darkroom
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. AppImage
|
||||||
|
|
||||||
|
A v1 channel, and the recipe is outstanding work rather than a decision to be
|
||||||
|
made. What it will have to account for, none of which is a surprise:
|
||||||
|
|
||||||
|
- **glibc.** An AppImage links against the oldest glibc it must run on, so it
|
||||||
|
is built in a container with an old base rather than on a rolling-release
|
||||||
|
developer machine. A release binary built on a current rolling-release host carries
|
||||||
|
`GLIBC_2.44` references and would run on almost nothing else.
|
||||||
|
- **What to bundle and what not to.** The binary links fontconfig, freetype,
|
||||||
|
expat, libpng, zlib, brotli and bzip2 — bundle those. It does *not* link
|
||||||
|
Vulkan, libxkbcommon or either display-server library: wgpu `dlopen`s
|
||||||
|
`libvulkan.so.1`, and `x11rb` and `wayland-client` speak the wire protocols
|
||||||
|
in Rust. The Vulkan loader and the ICD must come from the host, and bundling
|
||||||
|
a loader is the classic way to break an AppImage on a driver it did not
|
||||||
|
expect.
|
||||||
|
- **The models.** ~15 MB of ONNX weights inside the image, or a first-run
|
||||||
|
download. In-tree is consistent with how the Lensfun database ships and with
|
||||||
|
NFR-SEC-5's local-first posture; the licence question (D13) is the same one
|
||||||
|
it is everywhere else and is not made easier or harder by this channel.
|
||||||
|
- **No sandbox.** An AppImage tests nothing about FR-PLAT-LIN-3. It is a
|
||||||
|
convenience channel for distributions the PKGBUILD does not serve, and should
|
||||||
|
never be the channel a portal problem is discovered on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Android: F-Droid in v1, Play deferred
|
||||||
|
|
||||||
|
NFR-COMPAT-2 says Play distribution is what makes ARCH §6.9's constraints
|
||||||
|
binding, and that is worth reading precisely, because the constraint is already
|
||||||
|
met and would be met whatever the channel.
|
||||||
|
|
||||||
|
§6.9 is *verified*, not assumed: `MANAGE_EXTERNAL_STORAGE` is not grantable
|
||||||
|
under Play policy, and `READ_MEDIA_IMAGES` would not help because proprietary
|
||||||
|
RAW is not typed `image/*` by the platform scanner and does not appear in
|
||||||
|
`MediaStore.Images`. SAF is the only route that works, so FR-PLAT-AND-1 asks
|
||||||
|
for it unconditionally and `SourceRef` (ARCH §3.1) exists to make it possible.
|
||||||
|
A sideloaded or F-Droid build *could* ask for broader permissions; it would
|
||||||
|
gain nothing by doing so.
|
||||||
|
|
||||||
|
So the coupling runs the opposite way from how it is usually described. Play is
|
||||||
|
deferred for a reason that has nothing to do with storage: GPLv3 distribution
|
||||||
|
through Play is generally workable but has not been confirmed for this project
|
||||||
|
(ARCH §14), and F-Droid has no such question. Confirming it is a licence-reading
|
||||||
|
exercise; nothing in the storage design waits on the answer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Where the recipes live
|
||||||
|
|
||||||
|
```
|
||||||
|
packaging/
|
||||||
|
PKGBUILD Arch source package
|
||||||
|
paris.tourolle.darkroom.desktop the desktop entry, installed by every channel
|
||||||
|
paris.tourolle.darkroom.metainfo.xml AppStream, installed by every channel
|
||||||
|
flatpak/
|
||||||
|
paris.tourolle.darkroom.yml the manifest, and where the permissions are argued
|
||||||
|
```
|
||||||
|
|
||||||
|
`packaging/` also accumulates built `.pkg.tar.zst` artefacts from local
|
||||||
|
`makepkg` runs. Those are not part of any channel and should not be committed.
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
# DarkRoom — Outstanding work
|
||||||
|
|
||||||
|
**Status:** Living document · first written 2026-08-29
|
||||||
|
**Companion to:** [requirements.md §7](requirements.md), [technical-debt.md](technical-debt.md),
|
||||||
|
[traceability.md](traceability.md)
|
||||||
|
|
||||||
|
What is specified and not built, and for each cluster whether that is a decision, a dependency, or a
|
||||||
|
gap nobody has looked at.
|
||||||
|
|
||||||
|
This document exists because [traceability.md](traceability.md) cannot tell those apart. It reports
|
||||||
|
one number — the share of requirements carrying a `TRACES` tag — and a missing tag means either
|
||||||
|
"nobody has built this" or "somebody built it and did not say so". Both read the same way in the
|
||||||
|
summary table, which makes that figure pessimistic *and* uninformative at once: it understates what
|
||||||
|
works while hiding which of the remainder matters. Eight requirements gained a tag on this branch
|
||||||
|
because the code already satisfied them and nobody had said so. Everything below is the other kind.
|
||||||
|
|
||||||
|
It is also not a plan. [requirements.md §7](requirements.md) records what was deferred deliberately
|
||||||
|
and needs no argument; this records what is still nominally in scope, so that the distance between
|
||||||
|
the register and the binary is visible rather than something a reader has to reconstruct from a
|
||||||
|
percentage. Where the honest answer is "this requirement should be amended rather than met", it says
|
||||||
|
so — an unbuilt requirement that nobody intends to build is worse than a deferred one, because it
|
||||||
|
keeps costing attention.
|
||||||
|
|
||||||
|
**Four of these are being built right now**, in parallel worktrees, and are marked **⟳ in
|
||||||
|
progress** where they appear: focus peaking (part of FR-CULL-3), burst grouping (FR-CULL-5),
|
||||||
|
Flatpak packaging (FR-PLAT-LIN-3), and Android platform integration (FR-PLAT-AND-2/4/5/6). Strike
|
||||||
|
those lines as they land rather than rewriting around them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Plugins — 21 requirements, and a contradiction to resolve before any of them
|
||||||
|
|
||||||
|
**Untagged:** FR-PLG-1, -1a, -2a, -2b, -2c, -3, -3a, -4, -4a, -5, -5a, -5b, -5c, -6, -6a, -7, -8,
|
||||||
|
-9, -10, -11, -12.
|
||||||
|
|
||||||
|
No plugin host exists. No crate loads anything at runtime: there is no manifest reader, no WASM or
|
||||||
|
Lua engine, no registry, no signature check, no install path, no capability grant, no per-plugin
|
||||||
|
failure ledger. `declared/mod.rs` says as much in its own documentation — the operation format is
|
||||||
|
"not a plugin directory read at startup".
|
||||||
|
|
||||||
|
**Two of §3.10's requirements are met, and they are the interesting two.** FR-PLG-2 and FR-PLG-2d —
|
||||||
|
the declarative node format — are built and tagged: `core/dr-pipeline/ops/*.yaml` compiled by
|
||||||
|
`build.rs`, with the restricted expression grammar in `declared/expr.rs` and a parity test asserting
|
||||||
|
a declared operation and a hand-written one produce identical output.
|
||||||
|
[code-health.md §3](code-health.md) calls it "a working plugin system that happens to resolve at
|
||||||
|
build time", and that is exactly right. What is missing is not the format; it is everything that
|
||||||
|
would let somebody who is not in this repository use it.
|
||||||
|
|
||||||
|
**The contradiction.** [requirements.md §7](requirements.md) lists `| Plugin API | — |` among the
|
||||||
|
things deferred for v1 — a bare row, where most deferrals carry a justifying note. §3.10 then spends
|
||||||
|
roughly 280 lines and 23 requirement IDs specifying that same Plugin API in detail. Both statements
|
||||||
|
are in the register of record, and the traceability denominator counts the second one: 21 IDs, 12%
|
||||||
|
of all 179 defined requirements, worth about twelve points of coverage on their own — and nearly a
|
||||||
|
third of everything the matrix reports as uncovered. A reader looking at the coverage figure has no
|
||||||
|
way to know that, or that the subsystem behind it is one the same document says is not in this
|
||||||
|
version.
|
||||||
|
|
||||||
|
**And D16 is open.** [Decision D16](requirements.md) — plugin licensing — records that GPLv3
|
||||||
|
answers the derivative-work question differently for each of §3.10's three plugin forms, and that
|
||||||
|
this "must be answered *before* an ecosystem exists, not after", because a term introduced later
|
||||||
|
cannot be applied to plugins already written. D16 explicitly does not block FR-PLG-2; it blocks
|
||||||
|
publishing a third-party format as stable.
|
||||||
|
|
||||||
|
**What would resolve this:** an edit to `requirements.md`, not code. Either §7 drops the row, or
|
||||||
|
§3.10 is marked deferred with the two built requirements carved out. Until one of those happens the
|
||||||
|
coverage figure is measuring a decision that has already been taken, and taking it again every time
|
||||||
|
somebody reads the matrix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Culling — the stated differentiator, half built
|
||||||
|
|
||||||
|
[D11](requirements.md) names culling "the core differentiator". FR-CULL-1, -2, -4 and -8 through -12
|
||||||
|
are built. Four are not.
|
||||||
|
|
||||||
|
**FR-CULL-3 — Raw-truth overlays. All three bullets, unbuilt.** Focus peaking does not exist
|
||||||
|
anywhere; the string appears zero times in the tree.
|
||||||
|
|
||||||
|
The other two are easy to mistake for present, and are not. A histogram and clipping indicators do
|
||||||
|
exist — `dr-gpu/src/histogram.rs`, `ui/dr-ui/src/histogram.rs`, the panel in `histogram.slint` —
|
||||||
|
but they are tagged FR-DSP-7 and they answer the opposite question. They read `AdjustPass`'s 8-bit
|
||||||
|
output and count clipping as `r == 255`, which is to say they describe **the frame the display is
|
||||||
|
about to show**, after the whole develop chain has run. FR-CULL-3 asks for the histogram of the
|
||||||
|
*sensor data*, on the explicit grounds that a rendered image "systematically lies about what is
|
||||||
|
recoverable in the raw". A readout that measures the render cannot answer that however it is
|
||||||
|
presented, so this is not a matter of moving an existing widget into the culling view.
|
||||||
|
|
||||||
|
The requirement exists because a culling decision made against a rendered preview is a decision made
|
||||||
|
against the wrong image, and the whole of it is still to build. **⟳ in progress** (focus peaking).
|
||||||
|
|
||||||
|
**FR-CULL-5 — Burst and near-duplicate grouping.** Absent. Worth knowing before it is built:
|
||||||
|
`core/dr-face/src/calibrate.rs` already *assumes* it exists — "since FR-CULL-5 already groups
|
||||||
|
bursts, positives are bootstrapped from bursts" — and in fact bootstraps from confirmed labels
|
||||||
|
instead. That comment is a forward reference to this requirement and will need correcting either
|
||||||
|
way. `core/dr-catalog/src/dedup.rs` is not this: it is re-import detection under FR-CAT-11, matching
|
||||||
|
a file against one already catalogued, not two photographs against each other. **⟳ in progress**.
|
||||||
|
|
||||||
|
**FR-CULL-6 — Compare and survey.** Absent. No side-by-side view, no synchronised zoom or pan.
|
||||||
|
This is the one of the four with no adjacent machinery at all, and it is also the one that most
|
||||||
|
directly distinguishes culling from browsing.
|
||||||
|
|
||||||
|
**FR-CULL-7 — Culling on tablet.** Absent, and blocked by the three above rather than independent
|
||||||
|
of them: there is no separate tablet culling surface to build until there is something to put on it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. FR-DEV-3g — AI denoise
|
||||||
|
|
||||||
|
Promoted into v1 by [D11](requirements.md), and named there as the precondition for deferring AI
|
||||||
|
masking — the argument being that one learned stage earns the runtime that a second could then
|
||||||
|
reuse. Only classical noise reduction exists: `ops/noise_reduction.rs`, a bilateral filter in two
|
||||||
|
arrangements, exact for luminance and separable for chroma. It is good, and it is not this.
|
||||||
|
|
||||||
|
`models/` holds two face models and nothing else; `core/dr-segment/models/` holds a YOLO
|
||||||
|
segmentation model for subject masks. There is no denoise model, no learned demosaic, and no
|
||||||
|
inference path that is not face or segmentation.
|
||||||
|
|
||||||
|
The obstacle is not the pipeline. It is that [D13](requirements.md) — model licensing — is still
|
||||||
|
open for the models that already ship, and adding a third learned stage adds a third licence to
|
||||||
|
answer for. Building the runtime before that is settled means owning the same problem in one more
|
||||||
|
place.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The render path — FR-DSP-2, FR-DSP-4, NFR-RES-2
|
||||||
|
|
||||||
|
**FR-DSP-2 — Tiled computation. Unbuilt, and under challenge.** [architecture.md §6.2](architecture.md)
|
||||||
|
calls for tiling "from day one" on the grounds that retrofitting it is a rewrite. It was not built,
|
||||||
|
and the evidence has since moved. `core/dr-gpu/tests/frame_budget.rs` carries the argument in its
|
||||||
|
own header: one fused dispatch over a viewport-sized target is comfortably inside the frame budget,
|
||||||
|
and "if that stops being true, the recommendation to strike tiled computation from the interactive
|
||||||
|
path stops being supported, and this test is what says so."
|
||||||
|
[technical-debt.md TD-4](technical-debt.md) reaches the same place from the other direction — a
|
||||||
|
tiled convolution at clarity's radius reads nearly twice the taps that an untiled one does, so the
|
||||||
|
stage that looks most like it wants a tile cache is the stage that would be hurt most by one.
|
||||||
|
|
||||||
|
What exists is the declaration and not the mechanism: `DetailPass::radius` is documented as the halo
|
||||||
|
a tile would have to be grown by, with a test that pins it, and there is no scheduler to read it.
|
||||||
|
That is deliberate plumbing, not an oversight.
|
||||||
|
|
||||||
|
**So the open question here is not "when is tiling built" but "is FR-DSP-2 still a requirement".**
|
||||||
|
Two measurements say it costs more than it saves on the interactive path. Neither says anything
|
||||||
|
about the export path or about a device under memory pressure, which is where the case for it
|
||||||
|
actually lives — and that is spike S6, which has not run.
|
||||||
|
|
||||||
|
**FR-DSP-4 — Progressive refinement.** Unbuilt. FR-DSP-1's proxy rendering and TD-4's
|
||||||
|
quarter-resolution base are adjacent and are not it: both are fixed choices about what resolution to
|
||||||
|
compute at, where FR-DSP-4 asks for a first frame that is deliberately cheap and a second that
|
||||||
|
replaces it. Nothing tracks a "this frame is provisional" state.
|
||||||
|
|
||||||
|
**NFR-RES-2 — Images larger than GPU memory.** No answer, and §4.3 knows it: the requirement text
|
||||||
|
itself asks the reader to "decide explicitly" how ARCH §6.4 and NFR-RES-2 are reconciled. There is
|
||||||
|
no headroom budget, no allocation-failure fallback, and no spill. Spike S6 — a tiled pipeline on a
|
||||||
|
mid-range Android device with an image larger than available GPU memory — is the one that would
|
||||||
|
settle both this and FR-DSP-2, and there is no evidence it has run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Android beyond running, and Flatpak
|
||||||
|
|
||||||
|
The Android app is not a stub — it builds an APK, runs the whole application, unpacks bundled face
|
||||||
|
models, and has been measured on a tablet ([faces.md §12.1](faces.md),
|
||||||
|
[technical-debt.md TD-1](technical-debt.md)). What is missing is the platform contract around it.
|
||||||
|
|
||||||
|
**FR-PLAT-AND-1 is tagged and should not be relied on.** The requirement demands that library access
|
||||||
|
be obtained *exclusively* through the Storage Access Framework. There is no SAF code: no
|
||||||
|
`ACTION_OPEN_DOCUMENT_TREE`, no `takePersistableUriPermission`, no `DocumentsContract`. The two tags
|
||||||
|
rest on a `SourceRef::Document` variant that nothing constructs and a volumes helper, which is the
|
||||||
|
"plumbing a future feature would use" case [CONTRIBUTING.md](../CONTRIBUTING.md) and
|
||||||
|
[code-health.md CH-4](code-health.md) both warn about. Android reaches a library through a Nextcloud
|
||||||
|
account or a folder, over paths, like the desktop.
|
||||||
|
|
||||||
|
That has a consequence for the rest of the cluster: **FR-PLAT-AND-2** — detecting the loss of a
|
||||||
|
granted tree permission and marking images offline rather than deleting rows — cannot be built until
|
||||||
|
there is a permission to lose. It is listed here as unbuilt, but it is blocked, not skipped.
|
||||||
|
|
||||||
|
**FR-PLAT-AND-4** (managed background execution, foreground service for exports, stated Doze
|
||||||
|
behaviour): the manifest declares one activity, no service, and neither `FOREGROUND_SERVICE` nor
|
||||||
|
`POST_NOTIFICATIONS`. **FR-PLAT-AND-5** (`onTrimMemory` with a stated eviction order): no callback
|
||||||
|
is registered, though the eviction order it is supposed to drive is specified in FR-NC-6's text.
|
||||||
|
**FR-PLAT-AND-6** (view and share intents, `FileProvider`): the only intent filter is
|
||||||
|
`MAIN`/`LAUNCHER`. **⟳ in progress** for this group.
|
||||||
|
|
||||||
|
**FR-PLAT-LIN-3 — Flatpak.** `packaging/` holds an Arch `PKGBUILD` and a `.desktop` entry. There is
|
||||||
|
no Flatpak manifest, nothing goes through a portal, and `platform/dr-plat/src/secrets.rs` talks to
|
||||||
|
the Secret Service directly rather than through the portal the requirement names. **⟳ in progress**.
|
||||||
|
|
||||||
|
**NFR-COMPAT-2 — distribution channels.** Unstated, and this is the requirement that makes the
|
||||||
|
others binding: §4.8 observes that the decision to publish on Play is what turns SAF from a
|
||||||
|
preference into a constraint. Spike S11, the Play permissions dry-run that would settle it, has not
|
||||||
|
run. Related, NFR-COMPAT-1's baseline is real but scattered — API 28/36 live in the Android
|
||||||
|
Dockerfile and are checked in CI against the built ELF, which is good — while the items the
|
||||||
|
requirement singles out are missing: whether `shaderFloat16` and 16-bit storage are required (the
|
||||||
|
one it flags as jeopardising R1), minimum RAM, minimum desktop Mesa, and a named reference device
|
||||||
|
from a second GPU vendor.
|
||||||
|
|
||||||
|
**NFR-OPS-2 and NFR-OPS-4.** Crash reporting is a `log::error!` panic hook on Android and nothing at
|
||||||
|
all on desktop: no local crash record, no backtrace capture, no upload path and therefore no opt-in
|
||||||
|
gate to guard it. Update and first run are undefined; the concrete reason NFR-OPS-4 gives — that D2
|
||||||
|
pins rawler at a non-SemVer alpha whose camera-support fixes users will need — is unaddressed, and
|
||||||
|
there is no update mechanism of any kind.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Accessibility and internationalisation — the hard half is done and the easy half is not
|
||||||
|
|
||||||
|
**NFR-A11Y-1 — Localisation.** `@tr(` appears **zero** times across 14,482 lines of Slint. That
|
||||||
|
number overstates the problem, because the part that is genuinely architectural was got right:
|
||||||
|
`LocalizedKey` keeps display strings out of `core/` entirely, every operation publishes a key rather
|
||||||
|
than a label, and `labels::resolve` is the single point where a key becomes text. What that single
|
||||||
|
point does, however, is a hardcoded English `match` in Rust source — so changing a translation
|
||||||
|
requires a recompile, which is the one thing the requirement explicitly forbids. There is no message
|
||||||
|
catalogue in any format, no locale-resolution rule, and no decision recorded about RTL.
|
||||||
|
|
||||||
|
The work left is therefore smaller than it looks and entirely mechanical: a catalogue format, a load
|
||||||
|
path behind `resolve`, and `@tr(` around the Slint literals. The design it needs already exists.
|
||||||
|
|
||||||
|
**NFR-A11Y-2 — Accessibility.** `accessible-*` appears five times in the whole interface, all five
|
||||||
|
on one control — the parameter slider in `adjust.slint` — and nothing is set from the Rust side at
|
||||||
|
all. Everything else in eighteen Slint files is unnamed to AT-SPI and TalkBack. The requirement's own
|
||||||
|
caveat, that Slint's Android accessibility needs verifying, is spike S13, which has not run.
|
||||||
|
|
||||||
|
**NFR-A11Y-3 — Colour-independent status.** No compliance work found. This is cheap to satisfy while
|
||||||
|
a control is being written and expensive to retrofit across forty of them, which is an argument for
|
||||||
|
doing it as part of the NFR-A11Y-2 pass rather than after it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Catalog and sync
|
||||||
|
|
||||||
|
**FR-CAT-14 — Migration import.** Reading ratings, labels, keywords and collections out of a
|
||||||
|
Lightroom `.lrcat` or a darktable `library.db`. Unbuilt. The destination is not: keywords,
|
||||||
|
collections, ratings and the cross-device merge rules are all built and tested, and
|
||||||
|
`keywords.rs` already anticipates the arrival ("an import from Lightroom can bring in…"). What is
|
||||||
|
missing is only the two source adapters — which is a comparatively contained piece of work for a
|
||||||
|
requirement that decides whether somebody can try this software on a library they already have.
|
||||||
|
|
||||||
|
**FR-NC-11 — Initial catalog build.** Using WebDAV `SEARCH` (RFC 5323) against `/remote.php/dav/`,
|
||||||
|
filtered by mimetype and paginated, in preference to walking folders with PROPFIND. Unbuilt: no
|
||||||
|
`SEARCH` request is issued anywhere. The PROPFIND walk this exists to replace is fully built and
|
||||||
|
well optimised — ETag pruning under FR-NC-4 turns an unchanged 50k library into one request — so the
|
||||||
|
gap is narrower than it reads. It is the *first* build against a large remote library that pays, and
|
||||||
|
that is the moment a new user meets.
|
||||||
|
|
||||||
|
**FR-CAT-13 — XMP interoperability, tagged and not met.** Read and write standard XMP sidecars. The
|
||||||
|
single tag sits on `keywords.rs`, which stores keywords; no XMP is parsed or written anywhere in the
|
||||||
|
tree, and `dr-export`'s metadata module says so about its own half ("neither is read by `dr-decode`
|
||||||
|
today"). Listed here rather than silently, because a tag makes a gap invisible and this one is
|
||||||
|
load-bearing for interoperating with the editors FR-CAT-14 imports from.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. The performance targets are unverified, not unmet
|
||||||
|
|
||||||
|
Eleven of the fifteen §4.1 targets carry no tag: NFR-P2, -P3, -P4, -P6, -P7, -P8, -P10, -P11, -P12,
|
||||||
|
-P14, -P15. That is the uninteresting part of this section.
|
||||||
|
|
||||||
|
The interesting part is that §8 and §4.1 both require the same thing, in the same words, and it does
|
||||||
|
not exist: an automated benchmark suite against a synthetic 50k catalog, run per commit, where **"a
|
||||||
|
regression beyond a stated tolerance is a build failure, not a notification."** There is no
|
||||||
|
`benches/` directory in the workspace, no criterion dependency, and no synthetic catalog. The three
|
||||||
|
CI workflows run `cargo fmt --check`, clippy, `cargo test --workspace`, a release build, an Android
|
||||||
|
cross-build and a layering check. None of them measures anything, so there is no baseline to
|
||||||
|
regress against and no tolerance to exceed.
|
||||||
|
|
||||||
|
What does exist is narrower and genuinely good: `dr-gpu/examples/frame_budget` is a real instrument,
|
||||||
|
its results are committed in [frame-budget.md](frame-budget.md) with the machine and profile named,
|
||||||
|
and TD-4's before-and-after was measured with it. But it is run by hand — frame-budget.md's own
|
||||||
|
instruction is "rerun and diff this file" — and the guard version that does live in CI skips itself
|
||||||
|
where there is no GPU adapter, which the workflow notes is the normal case on a runner, while
|
||||||
|
asserting its CPU half only when `debug_assertions` is off, which a dev-profile `cargo test` is not.
|
||||||
|
In CI it therefore asserts approximately nothing.
|
||||||
|
|
||||||
|
**The claim to take from this is precise.** Nothing here says the performance targets are missed.
|
||||||
|
Several are plausibly met. It says that if one were broken tomorrow, nobody would find out — which
|
||||||
|
is the failure mode §8 was written to prevent, and the reason it belongs in this document rather
|
||||||
|
than in a backlog.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Two core requirements that cannot be closed as written
|
||||||
|
|
||||||
|
**R1 — Cross-platform output within a bounded tolerance.** §2 states that the threshold "must be
|
||||||
|
fixed before spike S9", because S9 both validates R1 and calibrates what tolerance is achievable.
|
||||||
|
The threshold was never fixed and S9 has not run, so R1 currently has no acceptance criterion at
|
||||||
|
all — there is nothing a test could assert.
|
||||||
|
|
||||||
|
Worse, the matrix reports R1 as *covered*. Both of its tags are string literals inside the
|
||||||
|
traceability tool's own unit tests (`tools/traceability/src/lib.rs`), which the tool scans along with
|
||||||
|
everything else, because a fixture demonstrating tag extraction is indistinguishable from a tag.
|
||||||
|
NFR-OPS-1 is covered the same way, from a tag on `compute_coverage` — and no rotating, size-capped
|
||||||
|
on-disk log exists; logging goes to stderr and logcat. These are two of the cases
|
||||||
|
[CONTRIBUTING.md](../CONTRIBUTING.md) already warns about, now named.
|
||||||
|
|
||||||
|
**R2 — Efficient display of huge RAW libraries.** Its acceptance criterion contains "*(figure
|
||||||
|
TBD)*" — the scroll velocity below which no cell may render as a placeholder — and asks for a stated
|
||||||
|
prefetch margin and cache-hit rate. No figure is stated anywhere in the tree, neither quantity is
|
||||||
|
measured, and [TD-2](technical-debt.md) and [TD-3](technical-debt.md) both describe the thumbnail
|
||||||
|
path falling short of it in ways that were measured. R2 was deliberately left untagged on this branch
|
||||||
|
for that reason: the machinery is substantial and the criterion is unmet and partly undefined.
|
||||||
|
|
||||||
|
Both belong with §8 above. A requirement whose threshold was never chosen and a target nothing
|
||||||
|
measures fail in the same way — not by being wrong, but by being unfalsifiable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Spikes
|
||||||
|
|
||||||
|
§9 defines fourteen validation spikes and says of three of them: "S1, S2 and S10 are the three that
|
||||||
|
can invalidate the architecture."
|
||||||
|
|
||||||
|
Only **S1** (Slint + wgpu zero-copy on Linux) and **S14** (the face pipeline on a real library) have
|
||||||
|
recorded results. S14's are the best evidence of any spike — a dedicated document, a measured pass
|
||||||
|
over an 18,143-face library, a named device and a reproducible command — though D13's licensing half
|
||||||
|
remains open.
|
||||||
|
|
||||||
|
**S6, S9, S10, S11 and S13 show no evidence of having run at all.** Each is referenced only from the
|
||||||
|
requirement text that asks for it:
|
||||||
|
|
||||||
|
| Spike | Would settle | Blocked on |
|
||||||
|
|---|---|---|
|
||||||
|
| S6 | FR-DSP-2, NFR-RES-2 — tiling and images larger than GPU memory | Nothing; needs a device and a large image |
|
||||||
|
| S9 | R1's tolerance threshold, and therefore R1 | Nothing; the threshold is defined *by* running it |
|
||||||
|
| S10 | Whether SAF at 10k files meets NFR-P1/P3 | §5 — there is no SAF code to measure |
|
||||||
|
| S11 | NFR-COMPAT-2, and whether Play makes SAF binding | Nothing |
|
||||||
|
| S13 | NFR-A11Y-2 on Android | §6 — there is almost nothing to test with |
|
||||||
|
|
||||||
|
S2, S3, S4, S5, S7, S8 and S12 are also unrun, several with acknowledgements in the code that say
|
||||||
|
so (`dr-sync/src/upload.rs` on S8, `dr-sync-nextcloud/src/lib.rs` on S3). S2 is one of the three
|
||||||
|
architecture-invalidating spikes and needs Adreno and Mali hardware, which the manifest notes no
|
||||||
|
emulator represents.
|
||||||
|
|
||||||
|
The pattern is worth stating rather than leaving to be inferred: the spikes that ran are the ones
|
||||||
|
whose subject was being built anyway. The ones that did not are the ones that would have said
|
||||||
|
whether something *should* be built — which is the opposite of the order §9 asks for.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. D12, which governs all of the above
|
||||||
|
|
||||||
|
[Decision D12 — scope versus pace](requirements.md) is still **OPEN**, and says:
|
||||||
|
|
||||||
|
> The calibration selected an ambitious feature set — full tablet editing, full ingest, culling as a
|
||||||
|
> differentiator, complete GPU masking, AI denoise, Fuji-first colour, deep sync, sidecar durability
|
||||||
|
> — against a stated pace of evenings and weekends, indefinitely.
|
||||||
|
>
|
||||||
|
> **Those are not compatible as stated.**
|
||||||
|
|
||||||
|
Sections 1 through 10 are what that incompatibility looks like eleven versions later, and they land
|
||||||
|
almost exactly where D12 predicted: tablet editing carries SAF at unproven scale, background
|
||||||
|
execution limits and two GPU vendors to validate (§5), and every one of those is unbuilt or unrun.
|
||||||
|
The parts that *were* built — the develop pipeline, sync, faces, the catalog — are the parts that
|
||||||
|
did not need a decision first.
|
||||||
|
|
||||||
|
D12 is not resolved by choosing to work faster. It is resolved by moving requirements across the
|
||||||
|
line into §7, which costs nothing but the admission, and which this document is intended to make
|
||||||
|
easy: every cluster above is a candidate, and each says what it would take to build and what it
|
||||||
|
would cost to drop. Resolving D12 sets D3 and [architecture.md §10](architecture.md)'s Phase 2.
|
||||||
@@ -55,6 +55,27 @@ readback is at viewport resolution, not sensor resolution. The 7.43 ms at 4K in
|
|||||||
not the bill. **It has not been measured on the device**, which is the first thing to do if the
|
not the bill. **It has not been measured on the device**, which is the first thing to do if the
|
||||||
develop view feels heavy on the tablet; do not assume this is the cause without a number.
|
develop view feels heavy on the tablet; do not assume this is the cause without a number.
|
||||||
|
|
||||||
|
### And a second transfer, while focus peaking is on
|
||||||
|
|
||||||
|
Added 2026-08-29 with FR-CULL-3. The focus-peaking overlay is a compute pass writing its own
|
||||||
|
`Rgba8Unorm` texture, which on desktop reaches the compositor with no copy — but on Android there is
|
||||||
|
no more a path for *that* texture than for the frame it belongs to, and an overlay that stayed on
|
||||||
|
the device while the picture underneath it did not would simply never be seen. So
|
||||||
|
`FocusPeakPass::read_overlay` follows the frame back through memory, and the Android frame path
|
||||||
|
carries **two** full-resolution `copy_texture_to_buffer` transfers instead of one.
|
||||||
|
|
||||||
|
This is recorded under TD-1 rather than as its own entry because it is not an independent choice.
|
||||||
|
It exists only because TD-1 exists, it is bounded by the same thing — `render` fits the pass to the
|
||||||
|
canvas, so both transfers are at viewport resolution — and TD-1's "Done when" already covers it:
|
||||||
|
whichever of the three fixes above lands removes the readback for the frame and the overlay
|
||||||
|
together, because both are the same missing capability.
|
||||||
|
|
||||||
|
Two things worth saying plainly. The doubling is **reasoned, not measured on the device** — the same
|
||||||
|
gap TD-1 admits about its own cost, and the reason neither number should be quoted as a measurement.
|
||||||
|
And it is paid only while the photographer has the overlay switched on: `DevelopSession::focus_overlay`
|
||||||
|
returns on its first line when peaking is off, so with it off there is no dispatch and no transfer,
|
||||||
|
and the Android frame path is exactly what it was before this feature existed.
|
||||||
|
|
||||||
### Paying it off
|
### Paying it off
|
||||||
|
|
||||||
Any one of these removes it:
|
Any one of these removes it:
|
||||||
|
|||||||
+84
-84
File diff suppressed because one or more lines are too long
@@ -37,6 +37,14 @@ package() {
|
|||||||
install -Dm644 "packaging/paris.tourolle.darkroom.desktop" \
|
install -Dm644 "packaging/paris.tourolle.darkroom.desktop" \
|
||||||
"${pkgdir}/usr/share/applications/paris.tourolle.darkroom.desktop"
|
"${pkgdir}/usr/share/applications/paris.tourolle.darkroom.desktop"
|
||||||
|
|
||||||
|
# The same AppStream file the Flatpak installs, so a software centre
|
||||||
|
# describes the two packages identically instead of falling back to the
|
||||||
|
# desktop entry's one-line Comment for this one. Installed here rather than
|
||||||
|
# written twice: the description, the licence fields and the OARS rating
|
||||||
|
# are facts about the application, not about how it was packaged.
|
||||||
|
install -Dm644 "packaging/paris.tourolle.darkroom.metainfo.xml" \
|
||||||
|
"${pkgdir}/usr/share/metainfo/paris.tourolle.darkroom.metainfo.xml"
|
||||||
|
|
||||||
# The icon's *name* is the contract, not its path: the desktop entry says
|
# The icon's *name* is the contract, not its path: the desktop entry says
|
||||||
# `Icon=paris.tourolle.darkroom` and the compositor resolves that through
|
# `Icon=paris.tourolle.darkroom` and the compositor resolves that through
|
||||||
# the hicolor theme. Installed under 256x256 because that is the source's
|
# the hicolor theme. Installed under 256x256 because that is the source's
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# Flatpak manifest — FR-PLAT-LIN-3 (sandboxed distribution), NFR-COMPAT-2.
|
||||||
|
#
|
||||||
|
# YAML rather than JSON because this file has more to explain than to declare,
|
||||||
|
# and JSON cannot hold a comment. flatpak-builder reads both.
|
||||||
|
#
|
||||||
|
# Build it, from the repository root:
|
||||||
|
#
|
||||||
|
# flatpak-builder --user --install --force-clean \
|
||||||
|
# build/flatpak packaging/flatpak/paris.tourolle.darkroom.yml
|
||||||
|
#
|
||||||
|
# Read docs/distribution.md before changing any permission below. Every line in
|
||||||
|
# `finish-args` is a hole in the sandbox, and the one that is conspicuously
|
||||||
|
# absent — `--filesystem=` — is absent on purpose and is explained there.
|
||||||
|
id: paris.tourolle.darkroom
|
||||||
|
|
||||||
|
# 25.08 is the current freedesktop runtime, and the choice is made by what the
|
||||||
|
# binary needs rather than by what is newest. `ldd` on a release build names
|
||||||
|
# fontconfig, freetype, expat, libpng, zlib, brotli and bzip2 — all in the
|
||||||
|
# Platform — and nothing else: Vulkan, libxkbcommon and the two display-server
|
||||||
|
# protocols are reached without a link-time dependency (wgpu dlopens
|
||||||
|
# libvulkan.so.1, and x11rb and wayland-client speak the wire protocols in Rust
|
||||||
|
# rather than binding libxcb or libwayland). So the runtime has to supply a
|
||||||
|
# Vulkan loader and an ICD at *runtime*, which is the GL extension's job, and
|
||||||
|
# not much else.
|
||||||
|
runtime: org.freedesktop.Platform
|
||||||
|
runtime-version: '25.08'
|
||||||
|
sdk: org.freedesktop.Sdk
|
||||||
|
|
||||||
|
# The Rust toolchain is an SDK extension rather than something this manifest
|
||||||
|
# installs, so the build is offline-capable in the part that matters and the
|
||||||
|
# compiler is the one freedesktop tested against its own glibc.
|
||||||
|
#
|
||||||
|
# Note what this quietly overrides: `rust-toolchain.toml` pins 1.92.0, and that
|
||||||
|
# pin is honoured by *rustup*, which is not what the extension provides. The
|
||||||
|
# extension's cargo therefore ignores the file and builds with its own stable
|
||||||
|
# (1.98.0 on 25.08). That is fine here and deliberately different from
|
||||||
|
# CONTRIBUTING.md's "do not override the toolchain": the pin exists so `cargo
|
||||||
|
# fmt --check` and `clippy -D warnings` agree between a laptop and CI, and
|
||||||
|
# neither runs in this build. A *release binary* only needs a compiler at or
|
||||||
|
# above the workspace's `rust-version`.
|
||||||
|
sdk-extensions:
|
||||||
|
- org.freedesktop.Sdk.Extension.rust-stable
|
||||||
|
|
||||||
|
command: darkroom-desktop
|
||||||
|
|
||||||
|
finish-args:
|
||||||
|
# FR-PLAT-LIN-2 asks for both display servers. `fallback-x11` rather than
|
||||||
|
# `x11`: it grants the X socket only when Wayland is unavailable, so a
|
||||||
|
# Wayland session does not leave an X11 hole open beside the socket actually
|
||||||
|
# in use. `--share=ipc` goes with it — without it X11 cannot use shared
|
||||||
|
# memory and every frame is pushed through the socket instead.
|
||||||
|
- --socket=wayland
|
||||||
|
- --socket=fallback-x11
|
||||||
|
- --share=ipc
|
||||||
|
|
||||||
|
# The GPU. The develop pipeline is compute shaders through wgpu and there is
|
||||||
|
# no CPU renderer behind it, so this is not an optimisation: without
|
||||||
|
# /dev/dri the application starts and cannot develop anything.
|
||||||
|
#
|
||||||
|
# `dri` rather than `all`: it covers the render nodes and the NVIDIA device
|
||||||
|
# nodes, which is the whole of what a Vulkan ICD opens. `all` would add every
|
||||||
|
# other device on the machine for no gain.
|
||||||
|
- --device=dri
|
||||||
|
|
||||||
|
# Nextcloud (FR-NC-*). Nothing else here reaches the network — face grouping,
|
||||||
|
# segmentation and lens correction are all local and stay local (NFR-SEC-5).
|
||||||
|
- --share=network
|
||||||
|
|
||||||
|
# Credential storage (FR-NC-2). The keyring crate speaks the Secret Service
|
||||||
|
# D-Bus interface directly, which GNOME Keyring and KWallet's `ksecretd` both
|
||||||
|
# implement, so what it needs is a talk hole to that well-known name.
|
||||||
|
#
|
||||||
|
# Worth being precise, because FR-PLAT-LIN-3 says "Secret Service portal" and
|
||||||
|
# these are two different things: xdg-desktop-portal's `org.freedesktop.
|
||||||
|
# portal.Secret` hands an application a master key for a store it keeps
|
||||||
|
# itself, whereas this talks to the session's secret daemon. Only the latter
|
||||||
|
# puts the app password where `secret-tool` and Seahorse can see it, which is
|
||||||
|
# what makes a credential individually revocable by the user rather than
|
||||||
|
# opaque inside our own data directory. If no daemon answers, FR-NC-2's
|
||||||
|
# degraded mode is what the user gets — the same behaviour as outside a
|
||||||
|
# sandbox, which is the point.
|
||||||
|
- --talk-name=org.freedesktop.secrets
|
||||||
|
|
||||||
|
# No `--filesystem=` line of any kind, and this is the substance of
|
||||||
|
# FR-PLAT-LIN-3 rather than an omission.
|
||||||
|
#
|
||||||
|
# What that leaves working: /run/user/$UID/doc is mounted in every sandbox, so
|
||||||
|
# a photograph opened from a file manager — the .desktop entry declares the
|
||||||
|
# RAW MIME types and `Exec=darkroom-desktop %F` — arrives as a document-portal
|
||||||
|
# path in argv and opens. That path is genuinely portal-mediated and needs no
|
||||||
|
# code change.
|
||||||
|
#
|
||||||
|
# What that leaves broken: choosing a *library root*. The folder connector
|
||||||
|
# takes a typed absolute path (`SignIn::EndpointOnly`, placeholder
|
||||||
|
# `/home/you/Pictures`) and checks it with `std::fs`, and nothing in the tree
|
||||||
|
# calls the FileChooser portal — there is no ashpd, no rfd, no toolkit dialog.
|
||||||
|
# A path typed into that field does not exist in this sandbox, so the launch
|
||||||
|
# screen refuses it with "that folder does not exist", which is at least an
|
||||||
|
# honest error.
|
||||||
|
#
|
||||||
|
# `--filesystem=host` would make that work today and is exactly what the
|
||||||
|
# requirement forbids, so it is not here. docs/distribution.md §4 records what
|
||||||
|
# closes the gap and how to run a Flatpak build in the meantime.
|
||||||
|
|
||||||
|
modules:
|
||||||
|
- name: darkroom
|
||||||
|
buildsystem: simple
|
||||||
|
|
||||||
|
build-options:
|
||||||
|
append-path: /usr/lib/sdk/rust-stable/bin
|
||||||
|
env:
|
||||||
|
# Inside the build sandbox rather than in $HOME, so a rebuild starts
|
||||||
|
# from the state flatpak-builder is managing and not from whatever the
|
||||||
|
# host's cargo cache happens to hold.
|
||||||
|
CARGO_HOME: /run/build/darkroom/cargo
|
||||||
|
# Cargo fetches 826 crates, and Flathub's builders forbid this — a
|
||||||
|
# submission there needs `cargo-sources.json` generated by
|
||||||
|
# flatpak-builder-tools' `flatpak-cargo-generator.py` from Cargo.lock,
|
||||||
|
# listing every crate as its own source, plus a vendored-registry
|
||||||
|
# `.cargo/config.toml`. That file is ~30k lines, has to be regenerated on
|
||||||
|
# every dependency change, and buys nothing for a build from a local
|
||||||
|
# checkout, which is what this manifest is for and what packaging/PKGBUILD
|
||||||
|
# is for as well. Add it when there is a Flathub submission, not before.
|
||||||
|
build-args:
|
||||||
|
- --share=network
|
||||||
|
|
||||||
|
build-commands:
|
||||||
|
# `--locked` for the reason CI uses it: a lockfile that resolves
|
||||||
|
# differently in the packaging build than in the tree is a release whose
|
||||||
|
# dependency versions nobody chose.
|
||||||
|
- cargo build --release --locked -p darkroom-desktop
|
||||||
|
|
||||||
|
- install -Dm755 target/release/darkroom-desktop /app/bin/darkroom-desktop
|
||||||
|
|
||||||
|
- install -Dm644 packaging/paris.tourolle.darkroom.desktop
|
||||||
|
/app/share/applications/paris.tourolle.darkroom.desktop
|
||||||
|
|
||||||
|
- install -Dm644 packaging/paris.tourolle.darkroom.metainfo.xml
|
||||||
|
/app/share/metainfo/paris.tourolle.darkroom.metainfo.xml
|
||||||
|
|
||||||
|
# The icon's name is the contract, not its path — the desktop entry says
|
||||||
|
# `Icon=paris.tourolle.darkroom` and the shell resolves that through the
|
||||||
|
# hicolor theme. 256x256 because that is the source's actual size;
|
||||||
|
# installing it under a size it is not makes scaled icons look wrong.
|
||||||
|
- install -Dm644 ui/dr-ui/ui/app-icon.png
|
||||||
|
/app/share/icons/hicolor/256x256/apps/paris.tourolle.darkroom.png
|
||||||
|
|
||||||
|
# The face models, where `system_face_models_dirs()` looks: it reads
|
||||||
|
# $XDG_DATA_DIRS, which includes /app/share inside a Flatpak, so this is
|
||||||
|
# the same lookup that finds /usr/share/darkroom/models from the Arch
|
||||||
|
# package. Last in the search order, so a pair the user dropped in their
|
||||||
|
# own data directory still outranks these.
|
||||||
|
#
|
||||||
|
# These live in Git LFS. A checkout made without `git lfs pull` has
|
||||||
|
# ~130-byte pointers here, and `type: dir` below would copy the pointers
|
||||||
|
# in without complaint — producing a Flatpak whose face indexing fails
|
||||||
|
# inside the graph loader on the user's machine. Refuse instead, with the
|
||||||
|
# command that fixes it.
|
||||||
|
- |
|
||||||
|
for m in scrfd_500m_640.onnx arcface_mbf_b1.onnx; do
|
||||||
|
if [ "$(stat -c%s "models/face/$m")" -lt 100000 ]; then
|
||||||
|
echo "error: $m is an LFS pointer, not a model — run: git lfs pull" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
install -Dm644 "models/face/$m" "/app/share/darkroom/models/$m"
|
||||||
|
done
|
||||||
|
|
||||||
|
- install -Dm644 README.md /app/share/doc/darkroom/README.md
|
||||||
|
|
||||||
|
sources:
|
||||||
|
# The local checkout, for the same reason packaging/PKGBUILD builds from
|
||||||
|
# one: this makes a Flatpak of what you are actually working on. Swap it
|
||||||
|
# for an `archive` or `git` source with a tag when there is a release to
|
||||||
|
# point at.
|
||||||
|
#
|
||||||
|
# `skip` is not tidiness. `target/` is tens of gigabytes and `.git` with
|
||||||
|
# LFS objects is not small; flatpak-builder copies a `dir` source
|
||||||
|
# wholesale, so without these two lines the copy is the slowest part of
|
||||||
|
# the build by a wide margin.
|
||||||
|
- type: dir
|
||||||
|
path: ../..
|
||||||
|
skip:
|
||||||
|
- target
|
||||||
|
- target-android
|
||||||
|
- .git
|
||||||
|
- build
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- Copyright 2026 Duncan Tourolle -->
|
||||||
|
<component type="desktop-application">
|
||||||
|
<!--
|
||||||
|
The component id, the .desktop basename and the Flatpak application id are
|
||||||
|
one string, not three that happen to agree. `dr_ui::run` sets the same
|
||||||
|
string as the Wayland app_id and winit's WM_CLASS, so a rename that misses
|
||||||
|
one of them costs the icon in the shell, the association in the software
|
||||||
|
centre, or both, and neither failure names itself.
|
||||||
|
-->
|
||||||
|
<id>paris.tourolle.darkroom</id>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Two licence fields, two different things, and they are meant to differ.
|
||||||
|
`metadata_license` covers *this file* — software centres redistribute and
|
||||||
|
reformat catalogue metadata, so it has to be under something that permits
|
||||||
|
that unconditionally, which GPLv3 does not. `project_license` is the
|
||||||
|
application's own licence and is the one that must read GPL-3.0-or-later
|
||||||
|
to match D8 and the workspace manifest.
|
||||||
|
-->
|
||||||
|
<metadata_license>CC0-1.0</metadata_license>
|
||||||
|
<project_license>GPL-3.0-or-later</project_license>
|
||||||
|
|
||||||
|
<name>DarkRoom</name>
|
||||||
|
<summary>Non-destructive RAW photo library and editor</summary>
|
||||||
|
|
||||||
|
<description>
|
||||||
|
<p>
|
||||||
|
DarkRoom catalogues, culls and develops RAW photographs. Edits are stored
|
||||||
|
as a graph of operations beside the original rather than baked into it,
|
||||||
|
so every change stays reversible and the file the camera wrote is never
|
||||||
|
rewritten.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The library can live in a plain directory — a local disk, an external
|
||||||
|
drive, an NFS or SMB mount — or on a Nextcloud server, browsed and edited
|
||||||
|
without downloading whole RAW files first.
|
||||||
|
</p>
|
||||||
|
<p>Where it differs from the tools it sits beside:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Culling shows the camera's embedded preview immediately and replaces it with a full render when one is ready, so moving to the next frame does not wait on a demosaic</li>
|
||||||
|
<li>Sidecars are the record of an edit; the catalog is a cache that can be deleted and rebuilt</li>
|
||||||
|
<li>The develop pipeline runs on the GPU through Vulkan, including drawn masks — a working Vulkan driver is required, not merely preferred, because there is no CPU renderer behind it</li>
|
||||||
|
<li>Faces are detected and grouped locally — nothing is uploaded to identify anybody</li>
|
||||||
|
</ul>
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<launchable type="desktop-id">paris.tourolle.darkroom.desktop</launchable>
|
||||||
|
<provides>
|
||||||
|
<binary>darkroom-desktop</binary>
|
||||||
|
</provides>
|
||||||
|
|
||||||
|
<url type="homepage">https://gitea.tourolle.paris/dtourolle/DarkRoom</url>
|
||||||
|
<url type="bugtracker">https://gitea.tourolle.paris/dtourolle/DarkRoom/issues</url>
|
||||||
|
<url type="vcs-browser">https://gitea.tourolle.paris/dtourolle/DarkRoom</url>
|
||||||
|
|
||||||
|
<developer id="paris.tourolle">
|
||||||
|
<name>Duncan Tourolle</name>
|
||||||
|
</developer>
|
||||||
|
|
||||||
|
<categories>
|
||||||
|
<category>Graphics</category>
|
||||||
|
<category>Photography</category>
|
||||||
|
</categories>
|
||||||
|
|
||||||
|
<keywords>
|
||||||
|
<keyword>RAW</keyword>
|
||||||
|
<keyword>photography</keyword>
|
||||||
|
<keyword>develop</keyword>
|
||||||
|
<keyword>darkroom</keyword>
|
||||||
|
<keyword>catalog</keyword>
|
||||||
|
</keywords>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
D15 decided the target devices are a 12-inch tablet and a desktop, with no
|
||||||
|
phone. Stating that here is what stops a software centre offering the
|
||||||
|
application on hardware the interface was never laid out for: the develop
|
||||||
|
view puts a photograph beside a parameter panel, and below roughly 768
|
||||||
|
logical pixels there is no arrangement of the two that is worth using.
|
||||||
|
`recommends` rather than `requires` for the input devices — touch alone is
|
||||||
|
usable, it is simply not what the sliders were designed around.
|
||||||
|
-->
|
||||||
|
<requires>
|
||||||
|
<display_length compare="ge">768</display_length>
|
||||||
|
</requires>
|
||||||
|
<recommends>
|
||||||
|
<control>pointing</control>
|
||||||
|
<control>keyboard</control>
|
||||||
|
<control>touch</control>
|
||||||
|
</recommends>
|
||||||
|
|
||||||
|
<content_rating type="oars-1.1"/>
|
||||||
|
|
||||||
|
<releases>
|
||||||
|
<release version="0.9.0" date="2026-08-29"/>
|
||||||
|
</releases>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,531 @@
|
|||||||
|
//! TRACES: FR-CULL-5
|
||||||
|
//! Burst grouping, as the library screen uses it.
|
||||||
|
//!
|
||||||
|
//! [`dr_catalog::bursts`] holds the grouping itself and knows nothing about
|
||||||
|
//! pixels. This is the other half: where the similarity signal comes from, and
|
||||||
|
//! how a group reaches a grid cell.
|
||||||
|
//!
|
||||||
|
//! # The signal comes out of the thumbnail store
|
||||||
|
//!
|
||||||
|
//! A perceptual signature needs pixels, and the cheapest pixels in the app are
|
||||||
|
//! the ones already sitting in `dr-thumbs`: a 256px JPEG per photograph, built
|
||||||
|
//! for the grid, shared between devices, and vastly more resolution than a 9×8
|
||||||
|
//! reduction can use. So this pass decodes thumbnails, never originals. A
|
||||||
|
//! library that has been browsed — or that has synced somebody else's shards —
|
||||||
|
//! has already paid for every signature it is about to get.
|
||||||
|
//!
|
||||||
|
//! The consequence, stated rather than hidden: **an image with no thumbnail
|
||||||
|
//! gets no signature, and a frame with no signature never joins a burst.** That
|
||||||
|
//! is self-correcting rather than permanent — the next pass finds the thumbnail
|
||||||
|
//! the sweep has since built — and it is the reason this runs when the
|
||||||
|
//! thumbnail sweep finishes rather than on a timer.
|
||||||
|
//!
|
||||||
|
//! # Why a pass and not a job
|
||||||
|
//!
|
||||||
|
//! Hashing is per-image and would make a perfectly good job kind. Grouping is
|
||||||
|
//! not: a burst is a property of a *run* of frames, so a per-image job would
|
||||||
|
//! regroup the library once per photograph. Since the two have to happen in that
|
||||||
|
//! order and the second cannot be split, both live in one pass — the same
|
||||||
|
//! argument docs/catalog.md §10.2 makes for face clustering.
|
||||||
|
//!
|
||||||
|
//! # It is never on the UI thread
|
||||||
|
//!
|
||||||
|
//! Decoding tens of thousands of thumbnails is bounded only by library size, and
|
||||||
|
//! the one thing that must not grow with library size is how long the window
|
||||||
|
//! stops answering (NFR-P9). Cancellation is dropping the receiver; a pass
|
||||||
|
//! abandoned half way leaves the signatures it did compute — they are permanent
|
||||||
|
//! and correct — and the previous grouping intact.
|
||||||
|
|
||||||
|
use std::cell::{Cell, RefCell};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::mpsc::Receiver;
|
||||||
|
|
||||||
|
use dr_catalog::bursts::{self, Rules, Signature};
|
||||||
|
use dr_catalog::Catalog;
|
||||||
|
use dr_thumbs::{ThumbSize, ThumbStore};
|
||||||
|
use dr_types::ImageId;
|
||||||
|
use slint::Model as _;
|
||||||
|
|
||||||
|
use crate::AppWindow;
|
||||||
|
|
||||||
|
/// How many signatures are written per transaction.
|
||||||
|
///
|
||||||
|
/// One transaction per image costs a WAL commit per thumbnail and turns a pass
|
||||||
|
/// over a real library into minutes of fsync; one transaction for the whole pass
|
||||||
|
/// holds a write lock for the duration and loses everything if the app closes.
|
||||||
|
/// A few hundred is the usual answer to that trade.
|
||||||
|
const WRITE_BATCH: usize = 256;
|
||||||
|
|
||||||
|
/// Progress from a grouping pass.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum BurstMessage {
|
||||||
|
/// How many images still need a signature. Sent once, before any decoding.
|
||||||
|
Started { to_hash: usize },
|
||||||
|
/// Cumulative signatures written.
|
||||||
|
Progress { hashed: usize },
|
||||||
|
/// The pass finished, and this is what the library now looks like.
|
||||||
|
Finished {
|
||||||
|
hashed: usize,
|
||||||
|
bursts: usize,
|
||||||
|
frames: usize,
|
||||||
|
},
|
||||||
|
/// It did not.
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash whatever is missing a signature, then rebuild the grouping.
|
||||||
|
///
|
||||||
|
/// Both halves run on a worker thread. The catalog is opened here rather than
|
||||||
|
/// shared with the UI's connection: SQLite connections are not `Send`, and WAL
|
||||||
|
/// is what makes a second one safe while the grid reads (NFR-R1).
|
||||||
|
pub fn spawn_grouping(catalog_path: PathBuf, thumbs_dir: PathBuf) -> Receiver<BurstMessage> {
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let catalog = match Catalog::open(&catalog_path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.send(BurstMessage::Failed(format!("cannot open catalog: {e}")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let conn = catalog.connection();
|
||||||
|
|
||||||
|
let outstanding = match bursts::images_without_signature(conn) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.send(BurstMessage::Failed(e.to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if tx
|
||||||
|
.send(BurstMessage::Started {
|
||||||
|
to_hash: outstanding.len(),
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A broken or absent store costs signatures, never correctness: the
|
||||||
|
// grouping still runs over whatever is already hashed, and the images
|
||||||
|
// that missed out are picked up by the next pass.
|
||||||
|
let store = match ThumbStore::open(&thumbs_dir) {
|
||||||
|
Ok(s) => Some(s),
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("thumbnail store unavailable, not hashing: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let hashed = match store {
|
||||||
|
Some(store) => hash_all(conn, &store, &outstanding, &tx),
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
match bursts::regroup(conn, Rules::default()) {
|
||||||
|
Ok(report) => {
|
||||||
|
log::info!(
|
||||||
|
"bursts: {hashed} signature(s) added, {} group(s) over {} frame(s), \
|
||||||
|
largest {}",
|
||||||
|
report.bursts,
|
||||||
|
report.frames,
|
||||||
|
report.largest
|
||||||
|
);
|
||||||
|
let _ = tx.send(BurstMessage::Finished {
|
||||||
|
hashed,
|
||||||
|
bursts: report.bursts,
|
||||||
|
frames: report.frames,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.send(BurstMessage::Failed(e.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
/// The running pass's drain timer, and whether one is running.
|
||||||
|
///
|
||||||
|
/// Module-local rather than a pair of fields on the library controller, so
|
||||||
|
/// that everything this feature needs to run lives in this file and the
|
||||||
|
/// screen that starts it is left holding nothing. Safe as a thread local
|
||||||
|
/// because Slint's event loop is single-threaded (NFR-P9) and this is only
|
||||||
|
/// ever touched from it.
|
||||||
|
///
|
||||||
|
/// The flag is separate because stopping a timer does not drop it: a slot
|
||||||
|
/// tested for emptiness would refuse every pass after the first.
|
||||||
|
static DRAIN: RefCell<Option<slint::Timer>> = const { RefCell::new(None) };
|
||||||
|
static RUNNING: Cell<bool> = const { Cell::new(false) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash what has become hashable, then rebuild the library's burst grouping.
|
||||||
|
///
|
||||||
|
/// Fired when the thumbnail sweep finishes, because that is the moment the
|
||||||
|
/// signatures can all be computed: the pass reads thumbnails, and until the
|
||||||
|
/// sweep has run most images have none. Not on a timer, and not after every
|
||||||
|
/// scan — a regroup is cheap but not free, and nothing is waiting on it.
|
||||||
|
///
|
||||||
|
/// `grouped` is called once, with the number of bursts the library now has, if
|
||||||
|
/// the pass finishes. It is where the caller reloads the grid: the cells hold
|
||||||
|
/// the same photographs they held before — a new burst arrives open — but every
|
||||||
|
/// run of frames now carries a mark it did not have a moment ago, and only a
|
||||||
|
/// reload carries it.
|
||||||
|
///
|
||||||
|
/// Deliberately silent otherwise. The sweeps around it report progress because
|
||||||
|
/// they run for tens of minutes; this is seconds, and a status line for it would
|
||||||
|
/// be a line the user must read in order to learn nothing.
|
||||||
|
pub fn start_pass(catalog_path: PathBuf, thumbs_dir: PathBuf, grouped: impl Fn(usize) + 'static) {
|
||||||
|
// A second pass would read the same rows and write the same answer over the
|
||||||
|
// first one's transactions.
|
||||||
|
if RUNNING.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
RUNNING.set(true);
|
||||||
|
|
||||||
|
let rx = spawn_grouping(catalog_path, thumbs_dir);
|
||||||
|
let timer = slint::Timer::default();
|
||||||
|
timer.start(
|
||||||
|
slint::TimerMode::Repeated,
|
||||||
|
std::time::Duration::from_millis(400),
|
||||||
|
move || loop {
|
||||||
|
let msg = match rx.try_recv() {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||||||
|
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match msg {
|
||||||
|
BurstMessage::Started { to_hash } => {
|
||||||
|
log::info!("burst grouping: {to_hash} image(s) still to hash");
|
||||||
|
}
|
||||||
|
// Nothing on screen is showing this. Drained rather than
|
||||||
|
// ignored, because an unread channel is a worker that stalls.
|
||||||
|
BurstMessage::Progress { hashed } => {
|
||||||
|
log::debug!("burst grouping: {hashed} hashed so far");
|
||||||
|
}
|
||||||
|
BurstMessage::Finished {
|
||||||
|
hashed,
|
||||||
|
bursts,
|
||||||
|
frames,
|
||||||
|
} => {
|
||||||
|
log::info!(
|
||||||
|
"burst grouping: {hashed} signature(s) added, \
|
||||||
|
{bursts} group(s) over {frames} frame(s)"
|
||||||
|
);
|
||||||
|
finish();
|
||||||
|
grouped(bursts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
BurstMessage::Failed(e) => {
|
||||||
|
log::warn!("burst grouping: {e}");
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
DRAIN.with(|slot| *slot.borrow_mut() = Some(timer));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop draining, and let another pass start.
|
||||||
|
///
|
||||||
|
/// Stops the timer without dropping it — dropping one from inside its own
|
||||||
|
/// callback is not something to rely on — which is why the flag beside it is
|
||||||
|
/// what actually says whether a pass is running.
|
||||||
|
fn finish() {
|
||||||
|
RUNNING.set(false);
|
||||||
|
DRAIN.with(|slot| {
|
||||||
|
if let Some(timer) = slot.borrow().as_ref() {
|
||||||
|
timer.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode each image's stored thumbnail and record its signature.
|
||||||
|
///
|
||||||
|
/// Returns how many were written. Anything without a stored thumbnail, or whose
|
||||||
|
/// blob will not decode, is simply skipped — it keeps its NULL and comes back
|
||||||
|
/// next time, which is the same treatment `spawn_thumbnails` gives a corrupt
|
||||||
|
/// blob.
|
||||||
|
fn hash_all(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
store: &ThumbStore,
|
||||||
|
outstanding: &[ImageId],
|
||||||
|
tx: &std::sync::mpsc::Sender<BurstMessage>,
|
||||||
|
) -> usize {
|
||||||
|
let keys = thumbnail_keys(conn);
|
||||||
|
let mut pending: Vec<(ImageId, Signature)> = Vec::with_capacity(WRITE_BATCH);
|
||||||
|
let mut written = 0usize;
|
||||||
|
|
||||||
|
for image in outstanding {
|
||||||
|
let Some(file_id) = keys.get(image).copied() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// The grid class, not the large one: 256px is already thirty times the
|
||||||
|
// detail the reduction keeps, and asking for `Large` would miss most of
|
||||||
|
// the store, which is filled at `Grid`.
|
||||||
|
let Ok(Some(thumb)) = store.get(file_id, ThumbSize::Grid) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok((w, h, rgba)) = dr_thumbs::decode_rgba(&thumb.bytes) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(signature) = bursts::signature_of_rgba(&rgba, w, h) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
pending.push((*image, signature));
|
||||||
|
|
||||||
|
if pending.len() >= WRITE_BATCH {
|
||||||
|
written += flush(conn, &mut pending);
|
||||||
|
if tx.send(BurstMessage::Progress { hashed: written }).is_err() {
|
||||||
|
// The receiver is gone: the screen has moved on, and finishing
|
||||||
|
// the pass would be work nobody is waiting for.
|
||||||
|
return written;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
written += flush(conn, &mut pending);
|
||||||
|
written
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write one batch of signatures, emptying `pending`.
|
||||||
|
fn flush(conn: &rusqlite::Connection, pending: &mut Vec<(ImageId, Signature)>) -> usize {
|
||||||
|
if pending.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let n = pending.len();
|
||||||
|
let tx = match conn.unchecked_transaction() {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("recording signatures: {e}");
|
||||||
|
pending.clear();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (image, signature) in pending.drain(..) {
|
||||||
|
if let Err(e) = bursts::set_signature(&tx, image, signature) {
|
||||||
|
log::debug!("recording signature for {}: {e}", image.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match tx.commit() {
|
||||||
|
Ok(()) => n,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("committing signatures: {e}");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every image's thumbnail-store key, in one query.
|
||||||
|
///
|
||||||
|
/// Read whole rather than asked per image: the table is one small row per
|
||||||
|
/// photograph, and a query per image would be tens of thousands of statements
|
||||||
|
/// to save a megabyte.
|
||||||
|
fn thumbnail_keys(conn: &rusqlite::Connection) -> HashMap<ImageId, u64> {
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
let Ok(mut stmt) = conn.prepare("SELECT image_id, file_id FROM remote") else {
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
let Ok(rows) = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))) else {
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
for (image, file) in rows.flatten() {
|
||||||
|
out.insert(ImageId(image as u64), file as u64);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill in the burst badge on every cell of the loaded window.
|
||||||
|
///
|
||||||
|
/// One query for the window, in the same style as the collection badges and the
|
||||||
|
/// rating counts beside it — a grid that asks the catalog a question per cell is
|
||||||
|
/// a grid that stutters under a finger.
|
||||||
|
///
|
||||||
|
/// `ids` is the window in grid order, so the row index into the model is the
|
||||||
|
/// index into it.
|
||||||
|
pub fn sync_badges(window: &AppWindow, catalog: &Catalog, ids: &[ImageId]) {
|
||||||
|
if ids.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let found = match bursts::memberships(catalog.connection(), ids) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("reading burst membership: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let model = window.get_library_cells();
|
||||||
|
for (row, id) in ids.iter().enumerate() {
|
||||||
|
let (count, expanded) = match found.get(id) {
|
||||||
|
Some(m) => (m.size as i32, m.expanded),
|
||||||
|
// Not in a burst at all, which is most of a library.
|
||||||
|
None => (0, false),
|
||||||
|
};
|
||||||
|
if let Some(mut cell) = model.row_data(row) {
|
||||||
|
if cell.burst_count != count || cell.burst_expanded != expanded {
|
||||||
|
cell.burst_count = count;
|
||||||
|
cell.burst_expanded = expanded;
|
||||||
|
model.set_row_data(row, cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open or close the burst one cell belongs to.
|
||||||
|
///
|
||||||
|
/// Which direction is decided from what the catalog says the group is currently
|
||||||
|
/// doing, rather than from the cell's own `burst-expanded`. The cell is a copy
|
||||||
|
/// of that state and can be one reload behind; the table cannot.
|
||||||
|
///
|
||||||
|
/// The caller reloads the grid rather than repainting it, because collapsing
|
||||||
|
/// changes what the grid's *query* returns: the row count, the scrollbar and
|
||||||
|
/// the ordinal a scrub resolves all move together, and they can only stay in
|
||||||
|
/// step by being read again together.
|
||||||
|
///
|
||||||
|
/// Returns whether anything changed, so a click on a cell that is in no burst —
|
||||||
|
/// an entirely normal thing to happen — costs no round trip through the grid.
|
||||||
|
pub fn toggle(catalog: &Catalog, image: ImageId) -> bool {
|
||||||
|
let conn = catalog.connection();
|
||||||
|
let Ok(found) = bursts::memberships(conn, &[image]) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(membership) = found.get(&image) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
match bursts::set_expanded(conn, membership.burst_id, !membership.expanded) {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("toggling burst {}: {e}", membership.burst_id.0);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A catalog with two frames of one burst, and a thumbnail store holding a
|
||||||
|
/// picture for each.
|
||||||
|
///
|
||||||
|
/// `name` keeps two tests from sharing a directory, since they run in
|
||||||
|
/// parallel. Same shape as the store tests in `library`, which is also why
|
||||||
|
/// this reaches for `temp_dir` rather than a crate: nothing else here needs
|
||||||
|
/// one.
|
||||||
|
fn library(name: &str) -> (Catalog, PathBuf) {
|
||||||
|
let cat = Catalog::in_memory().unwrap();
|
||||||
|
let c = cat.connection();
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for (id, at) in [(1i64, 1000i64), (2, 1001)] {
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO images(id, root_id, source_ref, captured_at, camera, added_at)
|
||||||
|
VALUES (?1, 1, ?2, ?3, 'Canon EOS R5', 0)",
|
||||||
|
rusqlite::params![id, format!("IMG_{id}.CR3"), at],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)",
|
||||||
|
rusqlite::params![id, 100 + id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = std::env::temp_dir().join(format!("dr-ui-bursts-{name}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
{
|
||||||
|
let mut store = ThumbStore::open(&dir).unwrap();
|
||||||
|
for (id, shift) in [(1u64, 0usize), (2, 1)] {
|
||||||
|
let rgba = picture(shift);
|
||||||
|
let bytes = dr_thumbs::encode_rgba(64, 64, &rgba).unwrap();
|
||||||
|
store
|
||||||
|
.put(
|
||||||
|
100 + id,
|
||||||
|
ThumbSize::Grid,
|
||||||
|
&dr_thumbs::Thumbnail {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
bytes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(cat, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A blocky scene, moved sideways by `shift` pixels — one frame of a burst
|
||||||
|
/// and then the next.
|
||||||
|
fn picture(shift: usize) -> Vec<u8> {
|
||||||
|
let mut out = vec![0u8; 64 * 64 * 4];
|
||||||
|
for y in 0..64 {
|
||||||
|
for x in 0..64 {
|
||||||
|
let v = (((x + shift) / 8) * 37 + (y / 8) * 91) as u8;
|
||||||
|
let p = (y * 64 + x) * 4;
|
||||||
|
out[p] = v;
|
||||||
|
out[p + 1] = v;
|
||||||
|
out[p + 2] = v;
|
||||||
|
out[p + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_pass_hashes_from_thumbnails_and_groups_what_it_hashed() {
|
||||||
|
let (cat, dir) = library("hashes");
|
||||||
|
let conn = cat.connection();
|
||||||
|
let store = ThumbStore::open(&dir).unwrap();
|
||||||
|
let outstanding = bursts::images_without_signature(conn).unwrap();
|
||||||
|
assert_eq!(outstanding.len(), 2);
|
||||||
|
|
||||||
|
let (tx, _rx) = std::sync::mpsc::channel();
|
||||||
|
let hashed = hash_all(conn, &store, &outstanding, &tx);
|
||||||
|
assert_eq!(hashed, 2, "both thumbnails should have yielded a signature");
|
||||||
|
|
||||||
|
let report = bursts::regroup(conn, Rules::default()).unwrap();
|
||||||
|
assert_eq!(report.bursts, 1, "the two frames were not grouped");
|
||||||
|
assert_eq!(report.frames, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_image_with_no_thumbnail_keeps_its_null() {
|
||||||
|
let (cat, dir) = library("no-thumb");
|
||||||
|
let conn = cat.connection();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO images(id, root_id, source_ref, captured_at, added_at)
|
||||||
|
VALUES (9, 1, 'IMG_9.CR3', 1002, 0)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let store = ThumbStore::open(&dir).unwrap();
|
||||||
|
let outstanding = bursts::images_without_signature(conn).unwrap();
|
||||||
|
let (tx, _rx) = std::sync::mpsc::channel();
|
||||||
|
assert_eq!(hash_all(conn, &store, &outstanding, &tx), 2);
|
||||||
|
// And it is still offered next time, rather than being written off.
|
||||||
|
assert_eq!(
|
||||||
|
bursts::images_without_signature(conn).unwrap(),
|
||||||
|
vec![ImageId(9)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggling_a_cell_that_is_in_no_burst_changes_nothing() {
|
||||||
|
let (cat, _dir) = library("no-burst");
|
||||||
|
assert!(!toggle(&cat, ImageId(1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+151
-1
@@ -14,7 +14,8 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use dr_decode::RawImage;
|
use dr_decode::RawImage;
|
||||||
use dr_gpu::{
|
use dr_gpu::{
|
||||||
AdjustPass, DemosaicedImage, Demosaicer, GpuContext, Histogram, HistogramPass, MaskPass,
|
AdjustPass, DemosaicedImage, Demosaicer, FocusPeakPass, FocusPeaking, GpuContext, Histogram,
|
||||||
|
HistogramPass, MaskPass,
|
||||||
};
|
};
|
||||||
use dr_pipeline::mask::{MaskLayer, MaskSource};
|
use dr_pipeline::mask::{MaskLayer, MaskSource};
|
||||||
|
|
||||||
@@ -722,6 +723,25 @@ pub struct DevelopSession {
|
|||||||
/// old driver, a device without the storage-buffer atomics it needs — the
|
/// old driver, a device without the storage-buffer atomics it needs — the
|
||||||
/// photographer loses the histogram and keeps the photograph.
|
/// photographer loses the histogram and keeps the photograph.
|
||||||
histogram: Option<HistogramPass>,
|
histogram: Option<HistogramPass>,
|
||||||
|
/// TRACES: FR-CULL-3
|
||||||
|
/// The focus-peaking overlay, on the same terms as the histogram above:
|
||||||
|
/// optional, because a device that cannot compile the pass is still a
|
||||||
|
/// device that can develop the photograph. What is lost is an instrument,
|
||||||
|
/// not the picture.
|
||||||
|
peak: Option<FocusPeakPass>,
|
||||||
|
/// TRACES: FR-CULL-3
|
||||||
|
/// What the photographer asked the overlay to look like, or `None` for
|
||||||
|
/// off.
|
||||||
|
///
|
||||||
|
/// **Interface state, not part of the edit** — the same category as
|
||||||
|
/// `show_overlay` beside it. It changes no pixel of the photograph, it is
|
||||||
|
/// not in the sidecar, and it is not on the undo stack: pressing undo
|
||||||
|
/// after switching peaking on should take back the last *edit*, not the
|
||||||
|
/// last thing looked at.
|
||||||
|
///
|
||||||
|
/// An `Option` rather than a bool plus a settings field, so that "off" and
|
||||||
|
/// "on, in some configuration" cannot disagree with each other.
|
||||||
|
peaking: Option<FocusPeaking>,
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3
|
/// TRACES: FR-DEV-3
|
||||||
/// The region map local masks select from, once it has been computed.
|
/// The region map local masks select from, once it has been computed.
|
||||||
@@ -889,6 +909,10 @@ impl DevelopSession {
|
|||||||
histogram: HistogramPass::new(ctx)
|
histogram: HistogramPass::new(ctx)
|
||||||
.inspect_err(|e| log::warn!("no histogram on this device: {e}"))
|
.inspect_err(|e| log::warn!("no histogram on this device: {e}"))
|
||||||
.ok(),
|
.ok(),
|
||||||
|
peak: FocusPeakPass::new(ctx)
|
||||||
|
.inspect_err(|e| log::warn!("no focus peaking on this device: {e}"))
|
||||||
|
.ok(),
|
||||||
|
peaking: None,
|
||||||
segmentation: None,
|
segmentation: None,
|
||||||
masks: None,
|
masks: None,
|
||||||
subjects: None,
|
subjects: None,
|
||||||
@@ -2903,6 +2927,105 @@ impl DevelopSession {
|
|||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CULL-3
|
||||||
|
/// Whether this device could build the focus-peaking overlay.
|
||||||
|
///
|
||||||
|
/// Asked by the interface so that it can say the overlay is unavailable
|
||||||
|
/// rather than offer a switch that does nothing. The same courtesy the
|
||||||
|
/// histogram is not paid, and should be: a control that silently does
|
||||||
|
/// nothing is worse than one that is visibly absent.
|
||||||
|
pub fn peaking_available(&self) -> bool {
|
||||||
|
self.peak.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CULL-3
|
||||||
|
/// What the overlay is set to, or `None` when it is off.
|
||||||
|
pub fn peaking(&self) -> Option<FocusPeaking> {
|
||||||
|
self.peaking
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CULL-3
|
||||||
|
/// Switch the overlay on with these settings, or off.
|
||||||
|
///
|
||||||
|
/// Asking for peaking on a device that could not build the pass leaves it
|
||||||
|
/// off, so that [`Self::peaking`] never claims something is being drawn
|
||||||
|
/// that is not. Switching off drops the overlay textures rather than
|
||||||
|
/// merely stopping drawing them: a resident overlay from the last frame is
|
||||||
|
/// one interface bug away from being laid over the next photograph.
|
||||||
|
pub fn set_peaking(&mut self, settings: Option<FocusPeaking>) {
|
||||||
|
self.peaking = settings.filter(|_| self.peak.is_some());
|
||||||
|
if self.peaking.is_none() {
|
||||||
|
if let Some(pass) = self.peak.as_mut() {
|
||||||
|
pass.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CULL-3 | NFR-P14
|
||||||
|
/// Mark the in-focus regions of the frame that is currently on the canvas.
|
||||||
|
///
|
||||||
|
/// **Reads the frame [`Self::render`] last produced**, exactly as
|
||||||
|
/// [`Self::histogram`] does and for the same reason: the overlay has to
|
||||||
|
/// describe what the photographer is looking at, and rendering a second
|
||||||
|
/// time to measure it would cost a pass and admit the possibility of the
|
||||||
|
/// two disagreeing about the picture.
|
||||||
|
///
|
||||||
|
/// That the frame is the displayed one is what makes the marks land where
|
||||||
|
/// the eye is. It is at viewport resolution, cropped and zoomed as the
|
||||||
|
/// view is, and — the point of FR-CULL-3 — descended from sensor data
|
||||||
|
/// through the demosaic rather than from the camera's embedded JPEG, whose
|
||||||
|
/// in-body sharpening this would otherwise be measuring at least as much
|
||||||
|
/// as the lens.
|
||||||
|
///
|
||||||
|
/// **Call this only after a settled render.** See
|
||||||
|
/// [`dr_gpu::FocusPeakPass::render`] for why a half-resolution draft frame
|
||||||
|
/// cannot be measured for sharpness.
|
||||||
|
///
|
||||||
|
/// `None` where nothing has been rendered, where peaking is off, or where
|
||||||
|
/// the device could not build the pass.
|
||||||
|
pub fn focus_overlay(&mut self) -> Option<slint::Image> {
|
||||||
|
let settings = self.peaking?;
|
||||||
|
// Cloned rather than borrowed: a `wgpu::Texture` handle is an `Arc`,
|
||||||
|
// and holding a shared borrow of `self.adjust` across the mutable
|
||||||
|
// borrow of `self.peak` would cost a `Self { .. }` destructure to say
|
||||||
|
// something the clone says in one word.
|
||||||
|
let frame = self.adjust.output()?.clone();
|
||||||
|
let pass = self.peak.as_mut()?;
|
||||||
|
let overlay = pass
|
||||||
|
.render(&frame, settings)
|
||||||
|
.inspect_err(|e| log::warn!("focus peaking failed: {e}"))
|
||||||
|
.ok()?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
{
|
||||||
|
// A layer over the canvas rather than a tint in it, so nothing
|
||||||
|
// here reaches the histogram or an export — see `FocusPeakPass`
|
||||||
|
// for the whole of that argument.
|
||||||
|
slint::Image::try_from(overlay)
|
||||||
|
.inspect_err(|e| log::warn!("the focus overlay is not importable: {e}"))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android draws with Skia over OpenGL and cannot sample a
|
||||||
|
// `wgpu::Texture`, so the overlay follows the frame it belongs to back
|
||||||
|
// through memory (technical-debt.md TD-1). The measurement still
|
||||||
|
// happens on the GPU; only this last hop does not.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
let _ = overlay;
|
||||||
|
let (rgba, w, h) = pass
|
||||||
|
.read_overlay()
|
||||||
|
.inspect_err(|e| log::warn!("reading the focus overlay back: {e}"))
|
||||||
|
.ok()?;
|
||||||
|
let mut buf = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(w, h);
|
||||||
|
let wanted = (w as usize) * (h as usize) * 4;
|
||||||
|
let src = &rgba[..wanted.min(rgba.len())];
|
||||||
|
buf.make_mut_bytes()[..src.len()].copy_from_slice(src);
|
||||||
|
Some(slint::Image::from_rgba8(buf))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Render the *whole* frame for the crop overlay to be drawn over.
|
/// Render the *whole* frame for the crop overlay to be drawn over.
|
||||||
///
|
///
|
||||||
/// Crop mode cannot use [`Self::render`]: that applies the crop, so the
|
/// Crop mode cannot use [`Self::render`]: that applies the crop, so the
|
||||||
@@ -2943,6 +3066,33 @@ impl DevelopSession {
|
|||||||
Ok((image, rw, rh))
|
Ok((image, rw, rh))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-PLAT-AND-5 | NFR-RES-1
|
||||||
|
/// Give back the GPU memory this session is holding only to be fast.
|
||||||
|
///
|
||||||
|
/// The edit is untouched: the graph and its history are CPU-side by
|
||||||
|
/// design (ARCH §6.1), so the photograph, the undo stack and the viewport
|
||||||
|
/// all survive and the next frame simply costs what the first one did.
|
||||||
|
///
|
||||||
|
/// # What is not released, and what it is waiting on
|
||||||
|
///
|
||||||
|
/// The demosaiced source is the largest single allocation a session holds
|
||||||
|
/// — a 24 MP frame is about 190 MB of `Rgba16Float` — and it is
|
||||||
|
/// deliberately kept. Dropping it would need the session to be able to
|
||||||
|
/// rebuild itself from the file, and rebuilding a session from a durable
|
||||||
|
/// record is FR-PLAT-AND-3, which is not built. Freeing it now would not
|
||||||
|
/// be an eviction; it would be closing the photograph without telling
|
||||||
|
/// anyone. Likewise the subject distance fields and the segmentation map:
|
||||||
|
/// each is guarded by a key recording what it was built from, and freeing
|
||||||
|
/// one without invalidating its key is the failure `AdjustPass` documents
|
||||||
|
/// under `colour_key`.
|
||||||
|
///
|
||||||
|
/// So this is the part of the GPU tier that can be given back and asked
|
||||||
|
/// for again with no other machinery, which is exactly as far as an
|
||||||
|
/// eviction should go.
|
||||||
|
pub fn release_gpu_caches(&mut self) {
|
||||||
|
self.adjust.release_caches();
|
||||||
|
}
|
||||||
|
|
||||||
/// The displayed size, for sizing the viewport.
|
/// The displayed size, for sizing the viewport.
|
||||||
///
|
///
|
||||||
/// The *framed* size, not the sensor's: cropping and quarter turns change
|
/// The *framed* size, not the sensor's: cropping and quarter turns change
|
||||||
|
|||||||
@@ -111,6 +111,21 @@ impl IdentityController {
|
|||||||
fn clear_picks(&self) {
|
fn clear_picks(&self) {
|
||||||
self.picked.borrow_mut().clear();
|
self.picked.borrow_mut().clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-PLAT-AND-5 | NFR-RES-1
|
||||||
|
/// Drop the decoded rail portraits.
|
||||||
|
///
|
||||||
|
/// The one in-memory image cache in this crate that is unbounded by
|
||||||
|
/// anything but the library: one decoded portrait per person, kept for as
|
||||||
|
/// long as the person exists. On a library with a few hundred named people
|
||||||
|
/// that is worth tens of megabytes of nothing but a saved decode.
|
||||||
|
///
|
||||||
|
/// Costless to lose. `refresh` rebuilds any portrait it does not find, so
|
||||||
|
/// the only consequence is the JPEG decode this cache exists to skip, and
|
||||||
|
/// only for the people the rail is actually showing at the time.
|
||||||
|
pub fn clear_covers(&self) {
|
||||||
|
self.covers.borrow_mut().clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push the people rail and the face grid into the window.
|
/// Push the people rail and the face grid into the window.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
//! in `ui/` names an operation or knows a shader exists (FR-DEV-3a).
|
//! in `ui/` names an operation or knows a shader exists (FR-DEV-3a).
|
||||||
|
|
||||||
mod activity;
|
mod activity;
|
||||||
|
mod bursts;
|
||||||
mod collections_ui;
|
mod collections_ui;
|
||||||
mod derived_sync;
|
mod derived_sync;
|
||||||
mod develop;
|
mod develop;
|
||||||
@@ -38,7 +39,10 @@ mod library_ui;
|
|||||||
#[cfg(live_style)]
|
#[cfg(live_style)]
|
||||||
mod live_style;
|
mod live_style;
|
||||||
mod masks_ui;
|
mod masks_ui;
|
||||||
|
pub mod memory;
|
||||||
mod net_runtime;
|
mod net_runtime;
|
||||||
|
mod peaking;
|
||||||
|
mod preset_store;
|
||||||
mod presets;
|
mod presets;
|
||||||
mod remote;
|
mod remote;
|
||||||
mod segmentation;
|
mod segmentation;
|
||||||
@@ -316,6 +320,12 @@ fn reset_view_state(window: &AppWindow) {
|
|||||||
// beside the next one's filename is a confident, precise lie, and the gap
|
// beside the next one's filename is a confident, precise lie, and the gap
|
||||||
// before the new frame settles is exactly long enough to read it.
|
// before the new frame settles is exactly long enough to read it.
|
||||||
window.set_histogram(histogram::empty());
|
window.set_histogram(histogram::empty());
|
||||||
|
// TRACES: FR-CULL-3
|
||||||
|
// The marks go down with it, and for the same reason. What is *not* reset
|
||||||
|
// is whether peaking is switched on: that is a way of looking at a folder
|
||||||
|
// rather than a property of one photograph, so it survives to the next
|
||||||
|
// frame — see `chosen_peaking` for the whole of that argument.
|
||||||
|
window.set_focus_overlay_ready(false);
|
||||||
// TRACES: FR-DEV-3
|
// TRACES: FR-DEV-3
|
||||||
// The region map belongs to one photograph. Carrying the stack, the
|
// The region map belongs to one photograph. Carrying the stack, the
|
||||||
// overlay or the crosshair to the next one would offer a selection of
|
// overlay or the crosshair to the next one would offer a selection of
|
||||||
@@ -1016,6 +1026,22 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
// each save over the other.
|
// each save over the other.
|
||||||
let settings = settings_ui::SettingsController::new();
|
let settings = settings_ui::SettingsController::new();
|
||||||
|
|
||||||
|
// TRACES: FR-PLAT-AND-5
|
||||||
|
// The thumbnail tier. Registered here, beside the thing it frees, so that
|
||||||
|
// a controller which grows another cache is one line from offering it up.
|
||||||
|
//
|
||||||
|
// Weak, not strong: `run` returns when the window closes, and a registry
|
||||||
|
// holding the last reference to a controller would keep it — and every
|
||||||
|
// decoded portrait in it — alive past the interface it belonged to.
|
||||||
|
{
|
||||||
|
let identity = std::rc::Rc::downgrade(&identity);
|
||||||
|
memory::evict_at(memory::Tier::Thumbnails, move || {
|
||||||
|
if let Some(ctl) = identity.upgrade() {
|
||||||
|
ctl.clear_covers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Launch screen: shown when there is nothing to display — no local paths
|
// Launch screen: shown when there is nothing to display — no local paths
|
||||||
// and no configured library. A user who has already signed in and chosen
|
// and no configured library. A user who has already signed in and chosen
|
||||||
// a folder goes straight to their images (FR-NC-1).
|
// a folder goes straight to their images (FR-NC-1).
|
||||||
@@ -1429,6 +1455,32 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
// The current develop session, if the file yielded sensor data.
|
// The current develop session, if the file yielded sensor data.
|
||||||
let session: Rc<RefCell<Option<DevelopSession>>> = Rc::new(RefCell::new(None));
|
let session: Rc<RefCell<Option<DevelopSession>>> = Rc::new(RefCell::new(None));
|
||||||
|
|
||||||
|
// TRACES: FR-PLAT-AND-5
|
||||||
|
// The GPU tier — the first thing given back under memory pressure, and on
|
||||||
|
// Android the only thing given back merely for going into the background.
|
||||||
|
//
|
||||||
|
// `try_borrow_mut` rather than `borrow_mut`, and the miss is not an error
|
||||||
|
// worth reporting. A memory warning can land in the middle of a render, at
|
||||||
|
// which point the slot is already borrowed and freeing its textures under
|
||||||
|
// the code drawing with them is not something to do politely — skipping is
|
||||||
|
// correct, because the pass that is running will have finished by the time
|
||||||
|
// the platform asks again, and a warning that has not been acted on is
|
||||||
|
// always followed by another one.
|
||||||
|
{
|
||||||
|
let session = Rc::downgrade(&session);
|
||||||
|
memory::evict_at(memory::Tier::Gpu, move || {
|
||||||
|
let Some(session) = session.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(mut slot) = session.try_borrow_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(open) = slot.as_mut() {
|
||||||
|
open.release_gpu_caches();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// TRACES: FR-DEV-6 | FR-CAT-8
|
// TRACES: FR-DEV-6 | FR-CAT-8
|
||||||
// The settings clipboard, and where the open image's edit is stored.
|
// The settings clipboard, and where the open image's edit is stored.
|
||||||
//
|
//
|
||||||
@@ -1468,11 +1520,24 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
// is redrawn, and those are very different rates.
|
// is redrawn, and those are very different rates.
|
||||||
let drawn_history: Rc<Cell<Option<u64>>> = Rc::new(Cell::new(None));
|
let drawn_history: Rc<Cell<Option<u64>>> = Rc::new(Cell::new(None));
|
||||||
|
|
||||||
|
// TRACES: FR-CULL-3
|
||||||
|
// How the photographer wants focus peaking drawn, or `None` for off.
|
||||||
|
//
|
||||||
|
// **Held here rather than on the session, which is the opposite of where
|
||||||
|
// every edit lives.** A session is one photograph; peaking is a way of
|
||||||
|
// *looking* at a folder of them. Someone culling three thousand frames
|
||||||
|
// switches it on once, and a flag that reset with the session would ask
|
||||||
|
// them to switch it on three thousand times — which is why
|
||||||
|
// `reset_view_state` deliberately leaves it alone while emptying the
|
||||||
|
// histogram beside it.
|
||||||
|
let chosen_peaking: Rc<Cell<Option<dr_gpu::FocusPeaking>>> = Rc::new(Cell::new(None));
|
||||||
|
|
||||||
let render_now: Render = {
|
let render_now: Render = {
|
||||||
let session = session.clone();
|
let session = session.clone();
|
||||||
let viewport = viewport.clone();
|
let viewport = viewport.clone();
|
||||||
let drawn_history = drawn_history.clone();
|
let drawn_history = drawn_history.clone();
|
||||||
let display = display.clone();
|
let display = display.clone();
|
||||||
|
let chosen_peaking = chosen_peaking.clone();
|
||||||
Rc::new(move |window: &AppWindow, draft: bool| {
|
Rc::new(move |window: &AppWindow, draft: bool| {
|
||||||
let mut slot = session.borrow_mut();
|
let mut slot = session.borrow_mut();
|
||||||
let Some(s) = slot.as_mut() else { return };
|
let Some(s) = slot.as_mut() else { return };
|
||||||
@@ -1533,6 +1598,16 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
// arrival takes.
|
// arrival takes.
|
||||||
spots_ui::sync_panel(window, s);
|
spots_ui::sync_panel(window, s);
|
||||||
|
|
||||||
|
// TRACES: FR-CULL-3
|
||||||
|
// The session owns the pass and the interface owns the choice, so
|
||||||
|
// they are joined here — on the one path every frame takes, which
|
||||||
|
// is also what makes a photograph opened with peaking already on
|
||||||
|
// arrive with its marks rather than without them.
|
||||||
|
if s.peaking() != chosen_peaking.get() {
|
||||||
|
s.set_peaking(chosen_peaking.get());
|
||||||
|
}
|
||||||
|
window.set_peaking_available(s.peaking_available());
|
||||||
|
|
||||||
let (mut w, mut h) = *viewport.borrow();
|
let (mut w, mut h) = *viewport.borrow();
|
||||||
|
|
||||||
// **Half resolution while the gesture is still moving.**
|
// **Half resolution while the gesture is still moving.**
|
||||||
@@ -1595,6 +1670,34 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
.map_or_else(histogram::empty, histogram::view),
|
.map_or_else(histogram::empty, histogram::view),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-CULL-3 | NFR-P14
|
||||||
|
// **Marked on the settled frame and no other**, and unlike
|
||||||
|
// the histogram beside it the marks are taken *down* in
|
||||||
|
// between rather than left standing.
|
||||||
|
//
|
||||||
|
// The reason is not budget — the dispatch is a fraction of
|
||||||
|
// a millisecond and would fit inside a draft frame
|
||||||
|
// comfortably. It is that peaking measures the top octave
|
||||||
|
// of the frame it is given, and a draft frame is rendered
|
||||||
|
// at half resolution: a defocused edge that spans four
|
||||||
|
// pixels there spans two, which is the signature of a
|
||||||
|
// sharp one. Measuring it would mark the out-of-focus
|
||||||
|
// background of every photograph, briefly, during every
|
||||||
|
// drag. A stale overlay is no better, because a pan moves
|
||||||
|
// the picture out from under it.
|
||||||
|
//
|
||||||
|
// So the marks pause while a control is moving and return
|
||||||
|
// when it stops, which the panel says out loud rather than
|
||||||
|
// leaving to be discovered.
|
||||||
|
let overlay = (!draft).then(|| s.focus_overlay()).flatten();
|
||||||
|
match overlay {
|
||||||
|
Some(image) => {
|
||||||
|
window.set_focus_overlay(image);
|
||||||
|
window.set_focus_overlay_ready(true);
|
||||||
|
}
|
||||||
|
None => window.set_focus_overlay_ready(false),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("render failed: {e}");
|
log::warn!("render failed: {e}");
|
||||||
@@ -1602,6 +1705,11 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
// No frame, so nothing to describe. The stale plot would
|
// No frame, so nothing to describe. The stale plot would
|
||||||
// otherwise sit beside the error message looking current.
|
// otherwise sit beside the error message looking current.
|
||||||
window.set_histogram(histogram::empty());
|
window.set_histogram(histogram::empty());
|
||||||
|
// TRACES: FR-CULL-3
|
||||||
|
// And nothing to mark. Focus marks over the last frame
|
||||||
|
// that rendered, beside a message saying this one did not,
|
||||||
|
// is the same confident lie in a second instrument.
|
||||||
|
window.set_focus_overlay_ready(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -2025,6 +2133,24 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
collections.clone(),
|
collections.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TRACES: FR-DEV-6
|
||||||
|
// The saved half of the same requirement, wired from the same bundle:
|
||||||
|
// applying a named preset to the open image is the paste path with a
|
||||||
|
// different source.
|
||||||
|
presets::wire_named(
|
||||||
|
&window,
|
||||||
|
presets::NamedPresets::open(),
|
||||||
|
presets::Develop {
|
||||||
|
session: session.clone(),
|
||||||
|
rows: rows.clone(),
|
||||||
|
redraw: redraw.clone(),
|
||||||
|
open: open_image.clone(),
|
||||||
|
},
|
||||||
|
settings.clone(),
|
||||||
|
library.clone(),
|
||||||
|
collections.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
// Close the knot left open beside `open_from_library`: the grid's
|
// Close the knot left open beside `open_from_library`: the grid's
|
||||||
// "‹ Library" button was wired before there was a session to save.
|
// "‹ Library" button was wired before there was a session to save.
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
@@ -2822,6 +2948,70 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-CULL-3
|
||||||
|
// The peaking switch and its two choices.
|
||||||
|
//
|
||||||
|
// All three write `chosen_peaking` and then redraw, because the marks are
|
||||||
|
// produced by a compute pass over the rendered frame: there is nothing the
|
||||||
|
// interface can change about the overlay that does not require the frame
|
||||||
|
// to be measured again. Turning peaking *off* redraws for the same reason
|
||||||
|
// — that render is what drops the overlay textures and clears the flag.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let chosen = chosen_peaking.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
window.on_peaking_toggled(move |on| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
// Built from the chips as they currently stand rather than from a
|
||||||
|
// remembered value: they are what the photographer can see, and an
|
||||||
|
// overlay that came back in a configuration the panel is not
|
||||||
|
// showing would be the panel lying about itself.
|
||||||
|
let next = on.then(|| dr_gpu::FocusPeaking {
|
||||||
|
sensitivity: peaking::sensitivity(w.get_peaking_sensitivity()),
|
||||||
|
colour: peaking::colour(w.get_peaking_colour()),
|
||||||
|
});
|
||||||
|
chosen.set(next);
|
||||||
|
w.set_peaking_on(next.is_some());
|
||||||
|
redraw(&w);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let chosen = chosen_peaking.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
window.on_peaking_sensitivity_picked(move |index| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
w.set_peaking_sensitivity(index);
|
||||||
|
// Only reachable while peaking is on — the chips are not drawn
|
||||||
|
// otherwise — but written as a conditional rather than an
|
||||||
|
// `expect`, because a panel is free to change its mind about that
|
||||||
|
// and nothing here should fall over when it does.
|
||||||
|
if let Some(mut current) = chosen.get() {
|
||||||
|
current.sensitivity = peaking::sensitivity(index);
|
||||||
|
chosen.set(Some(current));
|
||||||
|
redraw(&w);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let chosen = chosen_peaking.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
window.on_peaking_colour_picked(move |index| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
w.set_peaking_colour(index);
|
||||||
|
if let Some(mut current) = chosen.get() {
|
||||||
|
current.colour = peaking::colour(index);
|
||||||
|
chosen.set(Some(current));
|
||||||
|
redraw(&w);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// The chips open on whatever the vocabulary calls its default, so the
|
||||||
|
// panel and the pass agree before anything has been pressed.
|
||||||
|
window.set_peaking_sensitivity(peaking::sensitivity_index(Default::default()));
|
||||||
|
window.set_peaking_colour(peaking::colour_index(Default::default()));
|
||||||
|
|
||||||
// TRACES: FR-DSP-8 | FR-DSP-6
|
// TRACES: FR-DSP-8 | FR-DSP-6
|
||||||
// And which display that canvas is on, from now until the window closes.
|
// And which display that canvas is on, from now until the window closes.
|
||||||
display_ui::attach(&window, &display, &viewport, redraw.clone());
|
display_ui::attach(&window, &display, &viewport, redraw.clone());
|
||||||
|
|||||||
+192
-11
@@ -80,7 +80,20 @@ pub enum ScanMessage {
|
|||||||
/// amount of string matching on the far side can reliably recover it.
|
/// amount of string matching on the far side can reliably recover it.
|
||||||
/// Without the flag a dead connection and a bad password produce the same
|
/// Without the flag a dead connection and a bad password produce the same
|
||||||
/// banner, which sends the user to re-enter a credential that was fine.
|
/// banner, which sends the user to re-enter a credential that was fine.
|
||||||
Failed { message: String, offline: bool },
|
///
|
||||||
|
/// `lost_root` is the same idea one step further out, and it is carried
|
||||||
|
/// separately from `offline` rather than folded into it because the two
|
||||||
|
/// end differently. An offline library comes back when the network does,
|
||||||
|
/// with nothing asked of anyone; a library whose root cannot be opened
|
||||||
|
/// comes back only when someone restores access to it — a share put back
|
||||||
|
/// on the server, a drive plugged in, and in time a document tree granted
|
||||||
|
/// again once one can be (FR-PLAT-AND-2). Both show the same grid of what
|
||||||
|
/// is stored locally, and they must not offer the same explanation.
|
||||||
|
Failed {
|
||||||
|
message: String,
|
||||||
|
offline: bool,
|
||||||
|
lost_root: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One decoded thumbnail, ready for the grid.
|
/// One decoded thumbnail, ready for the grid.
|
||||||
@@ -186,6 +199,26 @@ const VISIBLE_UNALIASED: &str = "shadowed_by IS NULL AND trashed_at IS NULL";
|
|||||||
/// restore the same frame twice.
|
/// restore the same frame twice.
|
||||||
const TRASHED: &str = "i.shadowed_by IS NULL AND i.trashed_at IS NOT NULL";
|
const TRASHED: &str = "i.shadowed_by IS NULL AND i.trashed_at IS NOT NULL";
|
||||||
|
|
||||||
|
/// TRACES: FR-CULL-5
|
||||||
|
/// The clause that hides the frames a collapsed burst is standing in for.
|
||||||
|
///
|
||||||
|
/// Subject to exactly the discipline [`VISIBLE`] is under, and for the same
|
||||||
|
/// reason: the header's count, the scrollbar's size, the run a shift-click
|
||||||
|
/// resolves and the ordinal a scrub lands on are four answers about one list.
|
||||||
|
/// A burst folded away in the cells but still counted in the total would leave
|
||||||
|
/// the grid ending in rows that draw nothing, with no clue why.
|
||||||
|
///
|
||||||
|
/// The predicate itself is `dr_catalog::bursts`'s, not this file's, so the
|
||||||
|
/// interface and the pass that writes the table cannot come to disagree about
|
||||||
|
/// what collapsed means.
|
||||||
|
///
|
||||||
|
/// A function rather than a constant because it has to name the image table,
|
||||||
|
/// and the grid aliases it as `i` where the timeline's queries do not. `image`
|
||||||
|
/// is a table name from this file and never anything a user supplied.
|
||||||
|
fn uncollapsed(image: &str) -> String {
|
||||||
|
format!(" AND {}", dr_catalog::bursts::not_collapsed_away(image))
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-CAT-4
|
/// TRACES: FR-CAT-4
|
||||||
/// The order the grid lists photographs in: when they were taken.
|
/// The order the grid lists photographs in: when they were taken.
|
||||||
///
|
///
|
||||||
@@ -1146,6 +1179,7 @@ pub fn spawn_scan(
|
|||||||
let _ = tx.send(ScanMessage::Failed {
|
let _ = tx.send(ScanMessage::Failed {
|
||||||
message: e.message,
|
message: e.message,
|
||||||
offline: e.offline,
|
offline: e.offline,
|
||||||
|
lost_root: e.lost_root,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1160,6 +1194,7 @@ pub fn spawn_scan(
|
|||||||
struct ScanFailure {
|
struct ScanFailure {
|
||||||
message: String,
|
message: String,
|
||||||
offline: bool,
|
offline: bool,
|
||||||
|
lost_root: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScanFailure {
|
impl ScanFailure {
|
||||||
@@ -1169,6 +1204,7 @@ impl ScanFailure {
|
|||||||
Self {
|
Self {
|
||||||
message: message.to_string(),
|
message: message.to_string(),
|
||||||
offline: false,
|
offline: false,
|
||||||
|
lost_root: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1177,11 +1213,48 @@ impl From<dr_sync::RemoteError> for ScanFailure {
|
|||||||
fn from(e: dr_sync::RemoteError) -> Self {
|
fn from(e: dr_sync::RemoteError) -> Self {
|
||||||
Self {
|
Self {
|
||||||
offline: e.indicates_offline(),
|
offline: e.indicates_offline(),
|
||||||
|
lost_root: e.indicates_lost_root(),
|
||||||
message: e.to_string(),
|
message: e.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-PLAT-AND-2 | FR-CAT-9
|
||||||
|
/// Record that a library can no longer be opened, without losing it.
|
||||||
|
///
|
||||||
|
/// Called on the worker, before the failure crosses the channel, because this
|
||||||
|
/// is where the catalog handle is — and because the marking must be durable
|
||||||
|
/// whether or not anyone is left to draw a banner. A process killed between
|
||||||
|
/// the failure and the next launch must still come back knowing what it could
|
||||||
|
/// not reach.
|
||||||
|
///
|
||||||
|
/// Nothing is deleted. Every rating, every edit and every row stays exactly
|
||||||
|
/// where it was; what changes is that the images now say they are offline, so
|
||||||
|
/// the grid can show them as held-not-here rather than as ordinary
|
||||||
|
/// photographs whose thumbnails happen to be failing one at a time.
|
||||||
|
///
|
||||||
|
/// A root with no row yet is the first scan of a library that has never
|
||||||
|
/// succeeded, and there is nothing to mark — the failure alone is the whole
|
||||||
|
/// story, and the launch screen is where it is told.
|
||||||
|
fn mark_library_offline(catalog: &Catalog, root: &str) {
|
||||||
|
let conn = catalog.connection();
|
||||||
|
let root_id: Option<i64> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'",
|
||||||
|
[root],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
let Some(root_id) = root_id else {
|
||||||
|
log::info!("library {root} has no catalog root yet; nothing to mark offline");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match dr_catalog::mark_root_offline(conn, dr_types::RootId(root_id as u64)) {
|
||||||
|
Ok(()) => log::warn!("library {root} is unreachable; its images are marked offline"),
|
||||||
|
Err(e) => log::error!("could not mark {root} offline: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn run_scan(
|
fn run_scan(
|
||||||
tx: &Sender<ScanMessage>,
|
tx: &Sender<ScanMessage>,
|
||||||
conn: Connection,
|
conn: Connection,
|
||||||
@@ -1199,21 +1272,50 @@ fn run_scan(
|
|||||||
let rt = crate::net_runtime::build().map_err(ScanFailure::local)?;
|
let rt = crate::net_runtime::build().map_err(ScanFailure::local)?;
|
||||||
|
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let backend = crate::remote::connect(&conn).map_err(ScanFailure::local)?;
|
// TRACES: FR-PLAT-AND-2 | FR-CAT-9
|
||||||
|
// Classified rather than flattened to a local failure, because the
|
||||||
|
// removed-card case never gets as far as a request: the folder
|
||||||
|
// connector checks its root when it is constructed, so a library on an
|
||||||
|
// ejected card fails here and not in the walk. Reported as an ordinary
|
||||||
|
// error it left the grid showing a healthy library of images that
|
||||||
|
// could no longer be opened, one silent thumbnail failure at a time.
|
||||||
|
let backend = match crate::remote::connect(&conn) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
if e.indicates_lost_root() {
|
||||||
|
mark_library_offline(&catalog, &root);
|
||||||
|
}
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Stored folder ETags, so an unchanged subtree is skipped whole. On a
|
// Stored folder ETags, so an unchanged subtree is skipped whole. On a
|
||||||
// first run this is empty and the walk is complete; on every run after
|
// first run this is empty and the walk is complete; on every run after
|
||||||
// it is what keeps cost proportional to what changed (ARCH §8.4).
|
// it is what keeps cost proportional to what changed (ARCH §8.4).
|
||||||
let known = load_folder_etags(&catalog, &root);
|
let known = load_folder_etags(&catalog, &root);
|
||||||
|
|
||||||
let result = dr_sync::scan(&*backend, &RemotePath::new(&root), &filter, &known, |p| {
|
let scanned = dr_sync::scan(&*backend, &RemotePath::new(&root), &filter, &known, |p| {
|
||||||
let _ = tx.send(ScanMessage::Progress {
|
let _ = tx.send(ScanMessage::Progress {
|
||||||
directories: p.directories_listed,
|
directories: p.directories_listed,
|
||||||
pruned: p.directories_pruned,
|
pruned: p.directories_pruned,
|
||||||
images: p.images_found,
|
images: p.images_found,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.await?;
|
.await;
|
||||||
|
|
||||||
|
// TRACES: FR-PLAT-AND-2 | FR-CAT-9
|
||||||
|
// Written before the failure is reported, not after: the banner is a
|
||||||
|
// consequence of the catalog state and not the other way round, and a
|
||||||
|
// process that dies between the two must come back knowing.
|
||||||
|
let result = match scanned {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
if e.indicates_lost_root() {
|
||||||
|
mark_library_offline(&catalog, &root);
|
||||||
|
}
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
persist(&catalog, &root, &result).map_err(ScanFailure::local)?;
|
persist(&catalog, &root, &result).map_err(ScanFailure::local)?;
|
||||||
|
|
||||||
@@ -1306,13 +1408,27 @@ fn persist(
|
|||||||
.ok()
|
.ok()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TRACES: FR-PLAT-AND-2 | FR-CAT-9
|
||||||
|
// The `availability` arm is what ends an offline library, and it does
|
||||||
|
// it one photograph at a time. 3 is `Availability::Offline` and 0 is
|
||||||
|
// `MetadataOnly`, the same code this statement inserts new rows with —
|
||||||
|
// so a row that was marked offline when the root became unreachable is
|
||||||
|
// returned to exactly the state a fresh scan would have given it, and
|
||||||
|
// a row that was never marked is not touched at all.
|
||||||
|
//
|
||||||
|
// Conditional rather than a blanket reset for the same reason
|
||||||
|
// `dr_catalog::walk` restores per file rather than per root: the only
|
||||||
|
// thing that may clear "I could not reach this" is having reached it,
|
||||||
|
// and this statement runs precisely once per file the scan listed.
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO images(root_id, folder_id, source_ref, format, file_size,
|
"INSERT INTO images(root_id, folder_id, source_ref, format, file_size,
|
||||||
availability, metadata_state, added_at)
|
availability, metadata_state, added_at)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, 0, 1, ?6)
|
VALUES (?1, ?2, ?3, ?4, ?5, 0, 1, ?6)
|
||||||
ON CONFLICT(root_id, source_ref) DO UPDATE SET
|
ON CONFLICT(root_id, source_ref) DO UPDATE SET
|
||||||
file_size = excluded.file_size,
|
file_size = excluded.file_size,
|
||||||
folder_id = excluded.folder_id",
|
folder_id = excluded.folder_id,
|
||||||
|
availability = CASE WHEN images.availability = 3
|
||||||
|
THEN 0 ELSE images.availability END",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
root_id,
|
root_id,
|
||||||
folder_id,
|
folder_id,
|
||||||
@@ -3816,10 +3932,11 @@ pub fn read_cells_scoped(
|
|||||||
.join(",");
|
.join(",");
|
||||||
let rated = filter.sql();
|
let rated = filter.sql();
|
||||||
let (order, order_params) = grid_order_for(catalog, Some(scope));
|
let (order, order_params) = grid_order_for(catalog, Some(scope));
|
||||||
|
let folded = uncollapsed("i");
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT {CELL_COLUMNS}
|
"SELECT {CELL_COLUMNS}
|
||||||
FROM images i
|
FROM images i
|
||||||
WHERE {VISIBLE}{rated}
|
WHERE {VISIBLE}{rated}{folded}
|
||||||
AND i.id IN (SELECT image_id FROM collection_members
|
AND i.id IN (SELECT image_id FROM collection_members
|
||||||
WHERE collection_id IN ({placeholders}))
|
WHERE collection_id IN ({placeholders}))
|
||||||
{order}
|
{order}
|
||||||
@@ -3854,11 +3971,12 @@ fn read_cells_all(
|
|||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> {
|
) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> {
|
||||||
let rated = filter.sql();
|
let rated = filter.sql();
|
||||||
|
let folded = uncollapsed("i");
|
||||||
let mut rows = {
|
let mut rows = {
|
||||||
let mut stmt = catalog.connection().prepare(&format!(
|
let mut stmt = catalog.connection().prepare(&format!(
|
||||||
"SELECT {CELL_COLUMNS}
|
"SELECT {CELL_COLUMNS}
|
||||||
FROM images i
|
FROM images i
|
||||||
WHERE {VISIBLE}{rated}
|
WHERE {VISIBLE}{rated}{folded}
|
||||||
{GRID_ORDER}
|
{GRID_ORDER}
|
||||||
LIMIT ?1 OFFSET ?2"
|
LIMIT ?1 OFFSET ?2"
|
||||||
))?;
|
))?;
|
||||||
@@ -3964,10 +4082,15 @@ pub fn read_ids_span(
|
|||||||
// different ORDER BY names a different photograph.
|
// different ORDER BY names a different photograph.
|
||||||
let (order, order_params) = grid_order_for(catalog, scope);
|
let (order, order_params) = grid_order_for(catalog, scope);
|
||||||
params.extend(order_params);
|
params.extend(order_params);
|
||||||
|
// And the same folding, for the same reason one step further on: a
|
||||||
|
// collapsed burst is one cell in the grid, so an ordinal counted over
|
||||||
|
// a list that still held every frame of it would name a photograph
|
||||||
|
// several places away from the one the user pointed at.
|
||||||
|
let folded = uncollapsed("i");
|
||||||
(
|
(
|
||||||
format!(
|
format!(
|
||||||
"SELECT i.id FROM images i
|
"SELECT i.id FROM images i
|
||||||
WHERE {VISIBLE}{rated}{clause}
|
WHERE {VISIBLE}{rated}{folded}{clause}
|
||||||
{order}
|
{order}
|
||||||
LIMIT ? OFFSET ?"
|
LIMIT ? OFFSET ?"
|
||||||
),
|
),
|
||||||
@@ -4096,9 +4219,10 @@ pub fn total_images_scoped(
|
|||||||
// Counted through `images` rather than over `collection_members` alone, so
|
// Counted through `images` rather than over `collection_members` alone, so
|
||||||
// `VISIBLE` applies — a trashed photograph is still a member row, and
|
// `VISIBLE` applies — a trashed photograph is still a member row, and
|
||||||
// counting it made the header claim images the grid would not draw.
|
// counting it made the header claim images the grid would not draw.
|
||||||
|
let folded = uncollapsed("i");
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT count(DISTINCT i.id) FROM images i
|
"SELECT count(DISTINCT i.id) FROM images i
|
||||||
WHERE {VISIBLE}{rated}
|
WHERE {VISIBLE}{rated}{folded}
|
||||||
AND i.id IN (SELECT image_id FROM collection_members
|
AND i.id IN (SELECT image_id FROM collection_members
|
||||||
WHERE collection_id IN ({placeholders}))"
|
WHERE collection_id IN ({placeholders}))"
|
||||||
);
|
);
|
||||||
@@ -4409,8 +4533,9 @@ fn total_images_filtered(
|
|||||||
filter: &RatingFilter,
|
filter: &RatingFilter,
|
||||||
) -> Result<usize, dr_catalog::CatalogError> {
|
) -> Result<usize, dr_catalog::CatalogError> {
|
||||||
let rated = filter.sql();
|
let rated = filter.sql();
|
||||||
|
let folded = uncollapsed("i");
|
||||||
let n: i64 = catalog.connection().query_row(
|
let n: i64 = catalog.connection().query_row(
|
||||||
&format!("SELECT count(*) FROM images i WHERE {VISIBLE}{rated}"),
|
&format!("SELECT count(*) FROM images i WHERE {VISIBLE}{rated}{folded}"),
|
||||||
[],
|
[],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)?;
|
)?;
|
||||||
@@ -4956,12 +5081,16 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn the_window_read_walks_the_ordering_index() {
|
fn the_window_read_walks_the_ordering_index() {
|
||||||
let catalog = with_images(20);
|
let catalog = with_images(20);
|
||||||
|
// Including the burst clause, because the grid includes it: a
|
||||||
|
// predicate that quietly cost the ordering index would put the sort
|
||||||
|
// back and this is the only place that would notice.
|
||||||
|
let folded = uncollapsed("i");
|
||||||
let plan: Vec<String> = catalog
|
let plan: Vec<String> = catalog
|
||||||
.connection()
|
.connection()
|
||||||
.prepare(&format!(
|
.prepare(&format!(
|
||||||
"EXPLAIN QUERY PLAN
|
"EXPLAIN QUERY PLAN
|
||||||
SELECT {CELL_COLUMNS} FROM images i
|
SELECT {CELL_COLUMNS} FROM images i
|
||||||
WHERE {VISIBLE}
|
WHERE {VISIBLE}{folded}
|
||||||
{GRID_ORDER}
|
{GRID_ORDER}
|
||||||
LIMIT 10 OFFSET 5"
|
LIMIT 10 OFFSET 5"
|
||||||
))
|
))
|
||||||
@@ -5014,6 +5143,58 @@ mod tests {
|
|||||||
catalog
|
catalog
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CULL-5
|
||||||
|
/// A folded burst takes rows out of the cells, the count and the range a
|
||||||
|
/// shift-click resolves — all three, together.
|
||||||
|
///
|
||||||
|
/// This is the test that would fail if the clause were added to four of
|
||||||
|
/// the five queries that need it. That failure has no other symptom: the
|
||||||
|
/// header claims images the grid will not draw, the scrollbar sizes itself
|
||||||
|
/// for rows that are not there, and neither number looks wrong on its own.
|
||||||
|
#[test]
|
||||||
|
fn folding_a_burst_takes_the_same_rows_out_of_every_answer() {
|
||||||
|
use dr_catalog::bursts::{self, Rules, Signature};
|
||||||
|
|
||||||
|
let catalog = with_images(4);
|
||||||
|
// Three of the four are one burst: a second apart, one signature.
|
||||||
|
let ids = image_ids(&catalog);
|
||||||
|
for (n, id) in ids.iter().enumerate() {
|
||||||
|
let hash = if n < 3 { 0xFF00 } else { 0x00FF };
|
||||||
|
catalog
|
||||||
|
.connection()
|
||||||
|
.execute(
|
||||||
|
"UPDATE images SET captured_at = ?2, camera = 'Canon EOS R5',
|
||||||
|
perceptual_hash = ?3
|
||||||
|
WHERE id = ?1",
|
||||||
|
rusqlite::params![id.0 as i64, 1_000 + n as i64, Signature(hash).to_stored()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
bursts::regroup(catalog.connection(), Rules::default()).unwrap();
|
||||||
|
|
||||||
|
let filter = RatingFilter::default();
|
||||||
|
// Open, as a new burst is: nothing has been taken away yet.
|
||||||
|
assert_eq!(read_cells(&catalog, 0, 50).unwrap().len(), 4);
|
||||||
|
assert_eq!(total_images_filtered(&catalog, &filter).unwrap(), 4);
|
||||||
|
|
||||||
|
bursts::set_expanded(catalog.connection(), ids[0], false).unwrap();
|
||||||
|
|
||||||
|
let cells = read_cells(&catalog, 0, 50).unwrap();
|
||||||
|
assert_eq!(cells.len(), 2, "the folded frames are still in the cells");
|
||||||
|
assert_eq!(
|
||||||
|
total_images_filtered(&catalog, &filter).unwrap(),
|
||||||
|
cells.len(),
|
||||||
|
"the header's count and the cells disagree"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_ids_span(&catalog, None, &filter, false, 0, 49)
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
cells.len(),
|
||||||
|
"a shift-click over the whole grid would select frames it cannot show"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn image_ids(catalog: &Catalog) -> Vec<dr_types::ImageId> {
|
fn image_ids(catalog: &Catalog) -> Vec<dr_types::ImageId> {
|
||||||
let mut stmt = catalog
|
let mut stmt = catalog
|
||||||
.connection()
|
.connection()
|
||||||
|
|||||||
+153
-9
@@ -270,6 +270,22 @@ pub struct LibraryController {
|
|||||||
/// judgement, and carrying the old one over would report a server down
|
/// judgement, and carrying the old one over would report a server down
|
||||||
/// that was never contacted.
|
/// that was never contacted.
|
||||||
reachability: RefCell<dr_sync::Reachability>,
|
reachability: RefCell<dr_sync::Reachability>,
|
||||||
|
/// TRACES: FR-PLAT-AND-2 | FR-CAT-9
|
||||||
|
/// Why the library folder itself could not be opened, if it could not.
|
||||||
|
///
|
||||||
|
/// Beside [`Self::reachability`] rather than inside it, because
|
||||||
|
/// `dr_sync::Reachability` models *the server*, and it is deliberately
|
||||||
|
/// unmoved by a refusal — a forbidden file must not report the network as
|
||||||
|
/// down (see its own tests). A revoked tree grant is a refusal, so folding
|
||||||
|
/// it in would either break that rule or need an exception carved through
|
||||||
|
/// it.
|
||||||
|
///
|
||||||
|
/// Set only by a scan that failed at the root, and cleared only by one
|
||||||
|
/// that succeeded. Both states drive the same banner as being offline
|
||||||
|
/// does, because what the user can do is the same — carry on with what is
|
||||||
|
/// stored on the device — but the sentence under it is different, and so
|
||||||
|
/// is what will end it.
|
||||||
|
root_lost: RefCell<Option<String>>,
|
||||||
/// TRACES: FR-NC-6a
|
/// TRACES: FR-NC-6a
|
||||||
/// Drains the pin downloader. Held so a second pin replaces the timer
|
/// Drains the pin downloader. Held so a second pin replaces the timer
|
||||||
/// rather than leaving two draining the same finished channel.
|
/// rather than leaving two draining the same finished channel.
|
||||||
@@ -372,6 +388,7 @@ impl LibraryController {
|
|||||||
sidecar_timer: RefCell::new(None),
|
sidecar_timer: RefCell::new(None),
|
||||||
generation: std::cell::Cell::new(0),
|
generation: std::cell::Cell::new(0),
|
||||||
reachability: RefCell::new(dr_sync::Reachability::new()),
|
reachability: RefCell::new(dr_sync::Reachability::new()),
|
||||||
|
root_lost: RefCell::new(None),
|
||||||
outbox_timer: RefCell::new(None),
|
outbox_timer: RefCell::new(None),
|
||||||
outbox_maybe_dirty: std::cell::Cell::new(true),
|
outbox_maybe_dirty: std::cell::Cell::new(true),
|
||||||
geometry_timer: RefCell::new(None),
|
geometry_timer: RefCell::new(None),
|
||||||
@@ -443,10 +460,15 @@ impl LibraryController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-CAT-9
|
/// TRACES: FR-CAT-9 | FR-PLAT-AND-2
|
||||||
/// Whether the app currently believes the server is unreachable.
|
/// Whether the library cannot be reached, for either of the two reasons.
|
||||||
|
///
|
||||||
|
/// One answer rather than two because every caller asks it for the same
|
||||||
|
/// purpose: to decide whether starting a transfer is worth attempting.
|
||||||
|
/// A revoked grant fails that question exactly as a dead network does, and
|
||||||
|
/// a sync started against it would spend its retries proving it.
|
||||||
pub fn is_offline(&self) -> bool {
|
pub fn is_offline(&self) -> bool {
|
||||||
self.reachability.borrow().is_offline()
|
self.reachability.borrow().is_offline() || self.root_lost.borrow().is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the grid is narrowed to locally-stored originals.
|
/// Whether the grid is narrowed to locally-stored originals.
|
||||||
@@ -941,6 +963,17 @@ fn drain_scan(
|
|||||||
{
|
{
|
||||||
log::info!("back online");
|
log::info!("back online");
|
||||||
}
|
}
|
||||||
|
// TRACES: FR-PLAT-AND-2
|
||||||
|
// And it is the only evidence that clears a lost root,
|
||||||
|
// for the same reason: the walk began by listing the
|
||||||
|
// root, so a scan that finished is a root that opened.
|
||||||
|
// The rows it marked offline are restored one at a
|
||||||
|
// time by `library::persist`, as each file is listed
|
||||||
|
// again — this only stops the banner claiming what is
|
||||||
|
// no longer true.
|
||||||
|
if ctl.root_lost.borrow_mut().take().is_some() {
|
||||||
|
log::info!("library folder is readable again");
|
||||||
|
}
|
||||||
refresh_offline(&w, ctl);
|
refresh_offline(&w, ctl);
|
||||||
|
|
||||||
// An incremental rescan lists almost nothing, so
|
// An incremental rescan lists almost nothing, so
|
||||||
@@ -995,7 +1028,11 @@ fn drain_scan(
|
|||||||
stop(&ctl.scan_timer);
|
stop(&ctl.scan_timer);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ScanMessage::Failed { message, offline } => {
|
ScanMessage::Failed {
|
||||||
|
message,
|
||||||
|
offline,
|
||||||
|
lost_root,
|
||||||
|
} => {
|
||||||
log::warn!("scan failed: {message}");
|
log::warn!("scan failed: {message}");
|
||||||
w.set_library_scanning(false);
|
w.set_library_scanning(false);
|
||||||
// Recorded as a failure even where it is only the
|
// Recorded as a failure even where it is only the
|
||||||
@@ -1004,7 +1041,26 @@ fn drain_scan(
|
|||||||
// stopped because of it.
|
// stopped because of it.
|
||||||
job.fail(message.clone());
|
job.fail(message.clone());
|
||||||
|
|
||||||
if offline {
|
if lost_root {
|
||||||
|
// TRACES: FR-PLAT-AND-2 | FR-CAT-9
|
||||||
|
// The library folder itself could not be opened —
|
||||||
|
// a share withdrawn, an unplugged drive, and in
|
||||||
|
// time a revoked document-tree grant. The worker
|
||||||
|
// has already marked every row under this root
|
||||||
|
// offline and deleted none of them; this is the
|
||||||
|
// half the user sees.
|
||||||
|
//
|
||||||
|
// Tested first because it is also true that the
|
||||||
|
// library is unreachable, and the generic answer
|
||||||
|
// would be reached first and be less useful.
|
||||||
|
*ctl.root_lost.borrow_mut() = Some(message);
|
||||||
|
refresh_offline(&w, ctl);
|
||||||
|
// Same reason as the offline arm below: without
|
||||||
|
// this a launch that began with a revoked grant
|
||||||
|
// shows an empty grid, which is the one impression
|
||||||
|
// this whole path exists to avoid.
|
||||||
|
open_catalog_for_offline(&w, ctl, &catalog_path, &coll_ctl);
|
||||||
|
} else if offline {
|
||||||
// Not an error state. The catalog from the last
|
// Not an error state. The catalog from the last
|
||||||
// successful scan is still on disk and still
|
// successful scan is still on disk and still
|
||||||
// accurate for everything already indexed, so the
|
// accurate for everything already indexed, so the
|
||||||
@@ -1585,17 +1641,35 @@ fn scope_is_pinned(catalog: &Catalog, images: &[dr_types::ImageId]) -> bool {
|
|||||||
/// which is what keeps it testable without a display server.
|
/// which is what keeps it testable without a display server.
|
||||||
fn refresh_offline(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
fn refresh_offline(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||||
let reach = ctl.reachability.borrow();
|
let reach = ctl.reachability.borrow();
|
||||||
let offline = reach.is_offline();
|
// TRACES: FR-PLAT-AND-2
|
||||||
|
// A lost root wins over a dead network, and does so even when both are
|
||||||
|
// true — which is the ordinary case, since the scan that discovered the
|
||||||
|
// grant was gone was also the last request the app made. Reported the
|
||||||
|
// other way round the user is told to wait for a connection that is
|
||||||
|
// working, and the thing that would actually fix it is never mentioned.
|
||||||
|
let lost = ctl.root_lost.borrow();
|
||||||
|
let offline = reach.is_offline() || lost.is_some();
|
||||||
|
|
||||||
window.set_library_offline(offline);
|
window.set_library_offline(offline);
|
||||||
window.set_library_offline_reason(reach.reason().unwrap_or_default().into());
|
window.set_library_offline_reason(match lost.as_deref() {
|
||||||
|
Some(why) => why.into(),
|
||||||
|
None => reach.reason().unwrap_or_default().into(),
|
||||||
|
});
|
||||||
window.set_library_offline_since(
|
window.set_library_offline_since(
|
||||||
|
// A duration is what a network outage has and a revoked permission
|
||||||
|
// does not: "for 4 minutes" invites waiting, and waiting is precisely
|
||||||
|
// what will not help here.
|
||||||
|
if lost.is_some() {
|
||||||
|
slint::SharedString::default()
|
||||||
|
} else {
|
||||||
reach
|
reach
|
||||||
.offline_for(std::time::Instant::now())
|
.offline_for(std::time::Instant::now())
|
||||||
.map(describe_duration)
|
.map(describe_duration)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.into(),
|
.into()
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
drop(lost);
|
||||||
|
|
||||||
// A stale scan error under an offline banner reports one problem twice.
|
// A stale scan error under an offline banner reports one problem twice.
|
||||||
if offline {
|
if offline {
|
||||||
@@ -2214,6 +2288,11 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
|||||||
// window, not one per cell.
|
// window, not one per cell.
|
||||||
rating: 0,
|
rating: 0,
|
||||||
flag: 0,
|
flag: 0,
|
||||||
|
// And by `bursts::sync_badges`, in one more query for the
|
||||||
|
// window. Zero is "not in a burst", which is what almost every
|
||||||
|
// photograph in a library is.
|
||||||
|
burst_count: 0,
|
||||||
|
burst_expanded: false,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -2246,6 +2325,9 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
|||||||
.collect();
|
.collect();
|
||||||
crate::collections_ui::sync_badges(window, catalog, &ids);
|
crate::collections_ui::sync_badges(window, catalog, &ids);
|
||||||
sync_ratings(window, catalog, &ids);
|
sync_ratings(window, catalog, &ids);
|
||||||
|
// How many frames each cell stands for, where it stands for several
|
||||||
|
// (FR-CULL-5).
|
||||||
|
crate::bursts::sync_badges(window, catalog, &ids);
|
||||||
// The rebuilt cells all carry `selected: false`, but the selection itself
|
// The rebuilt cells all carry `selected: false`, but the selection itself
|
||||||
// is a set of image ids and survives untouched. Without this the ticks
|
// is a set of image ids and survives untouched. Without this the ticks
|
||||||
// vanished on every scroll — the selection was still there and still acted
|
// vanished on every scroll — the selection was still there and still acted
|
||||||
@@ -3769,6 +3851,28 @@ fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
|||||||
if !offline {
|
if !offline {
|
||||||
start_derived_sync(&w, &ctl_cb);
|
start_derived_sync(&w, &ctl_cb);
|
||||||
}
|
}
|
||||||
|
// And now every photograph in the library has a
|
||||||
|
// thumbnail, which is the only moment all of its burst
|
||||||
|
// signatures can be computed. See `bursts::start_pass`,
|
||||||
|
// which owns the pass and everything it needs to drain
|
||||||
|
// itself; what it wants from here is the paths and a
|
||||||
|
// way to say the grid has something new to draw.
|
||||||
|
if let Some((conn, _)) = ctl_cb.session.borrow().clone() {
|
||||||
|
let weak_after = w.as_weak();
|
||||||
|
let ctl_after = ctl_cb.clone();
|
||||||
|
crate::bursts::start_pass(
|
||||||
|
library::catalog_path(&conn.account),
|
||||||
|
library::thumbs_dir(&conn.account),
|
||||||
|
move |bursts| {
|
||||||
|
let Some(w) = weak_after.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if bursts > 0 && w.get_show_library() {
|
||||||
|
schedule_reload(&w, &ctl_after);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4119,10 +4223,14 @@ fn capture_time_from_catalog(catalog: &Catalog, ordinal: usize) -> Option<i64> {
|
|||||||
catalog
|
catalog
|
||||||
.connection()
|
.connection()
|
||||||
.query_row(
|
.query_row(
|
||||||
|
&format!(
|
||||||
"SELECT captured_at FROM images
|
"SELECT captured_at FROM images
|
||||||
WHERE shadowed_by IS NULL AND captured_at IS NOT NULL
|
WHERE shadowed_by IS NULL AND captured_at IS NOT NULL
|
||||||
|
AND {}
|
||||||
ORDER BY captured_at
|
ORDER BY captured_at
|
||||||
LIMIT 1 OFFSET ?1",
|
LIMIT 1 OFFSET ?1",
|
||||||
|
dr_catalog::bursts::not_collapsed_away("images")
|
||||||
|
),
|
||||||
[ordinal as i64],
|
[ordinal as i64],
|
||||||
|r| r.get::<_, i64>(0),
|
|r| r.get::<_, i64>(0),
|
||||||
)
|
)
|
||||||
@@ -4153,13 +4261,21 @@ fn scrub_to(window: &AppWindow, ctl: &Rc<LibraryController>, when: i64) {
|
|||||||
// BY), so they never precede a dated one and the predicate below stays
|
// BY), so they never precede a dated one and the predicate below stays
|
||||||
// a simple `<`. Shadowed rows are excluded here exactly as the grid
|
// a simple `<`. Shadowed rows are excluded here exactly as the grid
|
||||||
// excludes them.
|
// excludes them.
|
||||||
|
//
|
||||||
|
// A burst folded up occupies one row of the grid, so it must occupy one
|
||||||
|
// row of this count as well: an ordinal taken over the unfolded library
|
||||||
|
// would overshoot by every frame hidden earlier in it.
|
||||||
catalog
|
catalog
|
||||||
.connection()
|
.connection()
|
||||||
.query_row(
|
.query_row(
|
||||||
|
&format!(
|
||||||
"SELECT count(*) FROM images
|
"SELECT count(*) FROM images
|
||||||
WHERE shadowed_by IS NULL
|
WHERE shadowed_by IS NULL
|
||||||
AND captured_at IS NOT NULL
|
AND captured_at IS NOT NULL
|
||||||
AND captured_at < ?1",
|
AND captured_at < ?1
|
||||||
|
AND {}",
|
||||||
|
dr_catalog::bursts::not_collapsed_away("images")
|
||||||
|
),
|
||||||
[when],
|
[when],
|
||||||
|r| r.get::<_, i64>(0),
|
|r| r.get::<_, i64>(0),
|
||||||
)
|
)
|
||||||
@@ -4953,6 +5069,34 @@ pub fn wire<F>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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`].
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
window.on_library_burst_toggled(move |row| {
|
||||||
|
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 };
|
||||||
|
|
||||||
|
let changed = {
|
||||||
|
let borrow = ctl.catalog.borrow();
|
||||||
|
match borrow.as_ref() {
|
||||||
|
Some(catalog) => crate::bursts::toggle(catalog, id),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if changed {
|
||||||
|
ctl.requested.borrow_mut().clear();
|
||||||
|
load_window(&w, &ctl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// A rating or flag key. Applies to the whole selection, which is what
|
// A rating or flag key. Applies to the whole selection, which is what
|
||||||
// makes judging a run of frames one keystroke rather than forty.
|
// makes judging a run of frames one keystroke rather than forty.
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
//! TRACES: FR-PLAT-AND-5 | NFR-RES-1 | FR-NC-6b
|
||||||
|
//! Giving memory back when the platform asks for it.
|
||||||
|
//!
|
||||||
|
//! Android kills the process that will not shrink. It does not negotiate and
|
||||||
|
//! it does not warn twice, and the app it kills is the one holding the most —
|
||||||
|
//! which, on a photo editor, is always this one. So the question this module
|
||||||
|
//! answers is not "how much can be freed" but "in what order", because the
|
||||||
|
//! caches differ enormously in what losing them costs.
|
||||||
|
//!
|
||||||
|
//! # The order, and why it is that order
|
||||||
|
//!
|
||||||
|
//! FR-PLAT-AND-5 states it: GPU tiles first, then proxies, then thumbnails.
|
||||||
|
//! Read as a rule rather than a list, it is *cheapest to rebuild goes first* —
|
||||||
|
//! a GPU allocation is remade from data already in memory, a proxy is remade
|
||||||
|
//! from a file already on disk, and a thumbnail may cost a network fetch.
|
||||||
|
//! [`Tier`] is that order written down where the code can be held to it, so
|
||||||
|
//! adding a cache means choosing its tier rather than choosing its position in
|
||||||
|
//! a hand-maintained sequence.
|
||||||
|
//!
|
||||||
|
//! What each tier actually reaches in this build is documented on the variant,
|
||||||
|
//! including where it reaches nothing yet. An empty tier is worth keeping
|
||||||
|
//! visible: it says the order is complete and the coverage is not.
|
||||||
|
//!
|
||||||
|
//! # Why this is a registry rather than a function that frees things
|
||||||
|
//!
|
||||||
|
//! Every cache worth evicting lives behind an `Rc<RefCell<…>>` owned by a
|
||||||
|
//! local in [`crate::run`], which is a two-thousand-line function whose
|
||||||
|
//! callbacks each hold their own handle. There is no central object to reach
|
||||||
|
//! them through, and inventing one to serve eviction alone would be a large
|
||||||
|
//! change to how the interface is wired for a small change in what it does.
|
||||||
|
//!
|
||||||
|
//! So `run` hands this module a closure per cache as it builds each one, and
|
||||||
|
//! this module owns only the ordering. The registration is next to the thing
|
||||||
|
//! being registered, which is also the property that keeps it honest: a cache
|
||||||
|
//! added later is one line away from being evictable, and a cache removed
|
||||||
|
//! takes its sink with it.
|
||||||
|
//!
|
||||||
|
//! # Everything here is single-threaded, and that is not a limitation
|
||||||
|
//!
|
||||||
|
//! The registry is a `thread_local`, holding `Fn()` rather than `Fn() + Send`,
|
||||||
|
//! because the pressure signal already arrives on the thread that owns the
|
||||||
|
//! caches. Slint's Android backend calls the event listener from inside
|
||||||
|
//! `poll_events`, which runs on the same thread as the event loop, which is
|
||||||
|
//! the thread `run` built everything on. Marshalling through
|
||||||
|
//! `invoke_from_event_loop` would add a hop and a lifetime question to solve a
|
||||||
|
//! problem that does not exist — and would arrive *after* the moment the
|
||||||
|
//! system asked, which for a memory warning is the one thing that matters.
|
||||||
|
//!
|
||||||
|
//! Anything reached from a worker thread — the thumbnail store, the original
|
||||||
|
//! cache — is on disk and bounded by its own budget (NFR-RES-4), and is not
|
||||||
|
//! what a memory warning is about.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
/// How hard the platform is asking.
|
||||||
|
///
|
||||||
|
/// Two levels rather than Android's eight, because two is what the platform
|
||||||
|
/// actually delivers to this app. `ComponentCallbacks2.onTrimMemory` and its
|
||||||
|
/// `TRIM_MEMORY_*` grades are a Java callback on an `Activity` or
|
||||||
|
/// `Application`; a `NativeActivity` receives only `ANativeActivityCallbacks`,
|
||||||
|
/// whose memory callback is the ungraded `onLowMemory` — which is what
|
||||||
|
/// android-activity surfaces as `MainEvent::LowMemory`. Modelling grades the
|
||||||
|
/// entry point cannot observe would be modelling a wish.
|
||||||
|
///
|
||||||
|
/// [`Self::UiHidden`] recovers the one distinction that *is* observable and is
|
||||||
|
/// worth acting on, because it is the cheapest moment to give memory back:
|
||||||
|
/// nothing is on screen, so nothing that is freed has to be drawn again before
|
||||||
|
/// the user notices. It corresponds to `TRIM_MEMORY_UI_HIDDEN` in intent and
|
||||||
|
/// is derived from the activity being stopped rather than from a memory
|
||||||
|
/// warning at all.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Level {
|
||||||
|
/// The app is no longer on screen. Free what only a visible window needs.
|
||||||
|
UiHidden,
|
||||||
|
/// The system says it is short of memory. Free everything that can be
|
||||||
|
/// rebuilt.
|
||||||
|
Critical,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a cache costs to lose, as an order.
|
||||||
|
///
|
||||||
|
/// Declared in eviction order and iterated in declaration order by
|
||||||
|
/// [`Tier::ORDER`], so the sequence FR-PLAT-AND-5 specifies is a property of
|
||||||
|
/// this type rather than of each call site.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Tier {
|
||||||
|
/// GPU allocations that are rebuilt from data the process still holds.
|
||||||
|
///
|
||||||
|
/// The develop session's compiled pipelines, its detail intermediates and
|
||||||
|
/// its output textures. Rebuilt by the next render from the demosaiced
|
||||||
|
/// source, which is still resident — see
|
||||||
|
/// [`DevelopSession::release_gpu_caches`](crate::DevelopSession::release_gpu_caches)
|
||||||
|
/// for what is deliberately kept and what that is waiting on.
|
||||||
|
///
|
||||||
|
/// First because it is both the largest evictable pool on a mobile GPU and
|
||||||
|
/// the cheapest to refill: no I/O, no network, one frame's work.
|
||||||
|
Gpu,
|
||||||
|
/// Decoded image data rebuilt by reading a file again.
|
||||||
|
///
|
||||||
|
/// **Nothing registers here in this build, and the tier is kept anyway.**
|
||||||
|
/// There is no in-memory proxy cache: the only decoded full-size frame in
|
||||||
|
/// the process is the open develop session's, which belongs to
|
||||||
|
/// [`Tier::Gpu`] and cannot be dropped until a session can be rebuilt from
|
||||||
|
/// a durable record (FR-PLAT-AND-3). The on-disk original cache is a
|
||||||
|
/// different thing wearing the same word — freeing disk relieves no memory
|
||||||
|
/// pressure, and it already has a budget and an LRU of its own
|
||||||
|
/// (`dr_catalog::Cache`, NFR-RES-4).
|
||||||
|
Proxies,
|
||||||
|
/// Decoded thumbnails, rebuilt by decoding a stored JPEG again — or, at
|
||||||
|
/// worst, by fetching one.
|
||||||
|
///
|
||||||
|
/// Last because this is the tier a user sees losing: an evicted portrait
|
||||||
|
/// is a rail that redraws, and an evicted grid cell is a photograph that
|
||||||
|
/// greys out and comes back.
|
||||||
|
Thumbnails,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tier {
|
||||||
|
/// The eviction order, in one place.
|
||||||
|
pub const ORDER: [Tier; 3] = [Tier::Gpu, Tier::Proxies, Tier::Thumbnails];
|
||||||
|
|
||||||
|
/// Whether this tier is given up at this level of pressure.
|
||||||
|
///
|
||||||
|
/// Hiding the window frees the GPU tier and nothing else. That is not
|
||||||
|
/// caution about the rest — it is that a backgrounded app has no window to
|
||||||
|
/// draw and therefore no use at all for a render pipeline, while its
|
||||||
|
/// thumbnails are exactly what the user will be looking at half a second
|
||||||
|
/// after they come back. Under [`Level::Critical`] the process is being
|
||||||
|
/// measured against being killed, and a slow return beats no return.
|
||||||
|
fn evicted_at(self, level: Level) -> bool {
|
||||||
|
match level {
|
||||||
|
Level::UiHidden => matches!(self, Tier::Gpu),
|
||||||
|
Level::Critical => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cache that has offered itself up, and the tier it goes in.
|
||||||
|
///
|
||||||
|
/// Named because the registry is a `Vec` of these and the nested type is hard
|
||||||
|
/// to read at the use site rather than because either half means anything on
|
||||||
|
/// its own.
|
||||||
|
type Sink = (Tier, Box<dyn Fn()>);
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
/// Registered sinks, in the order they were registered within a tier.
|
||||||
|
///
|
||||||
|
/// Within a tier the order is registration order and nothing depends on
|
||||||
|
/// it; between tiers it is [`Tier::ORDER`], which everything depends on.
|
||||||
|
static SINKS: RefCell<Vec<Sink>> = const { RefCell::new(Vec::new()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offer a cache up for eviction at `tier`.
|
||||||
|
///
|
||||||
|
/// Called as each cache is built, so that the registration reads next to the
|
||||||
|
/// thing it is about. The closure is kept for the life of the thread; it must
|
||||||
|
/// therefore hold weak or shared handles rather than borrow anything, which is
|
||||||
|
/// the natural shape here because everything it can reach is already an `Rc`.
|
||||||
|
pub(crate) fn evict_at(tier: Tier, sink: impl Fn() + 'static) {
|
||||||
|
SINKS.with_borrow_mut(|sinks| sinks.push((tier, Box::new(sink))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-PLAT-AND-5
|
||||||
|
/// Give memory back, in [`Tier::ORDER`], as far down as `level` calls for.
|
||||||
|
///
|
||||||
|
/// Safe to call when nothing is registered — before the window is built, or on
|
||||||
|
/// a platform that never asks — in which case it does nothing at all.
|
||||||
|
///
|
||||||
|
/// The registry is taken out of the cell for the duration rather than borrowed
|
||||||
|
/// across the calls. A sink runs arbitrary interface code, and interface code
|
||||||
|
/// that registered another cache, or called this again, would otherwise meet a
|
||||||
|
/// `RefCell` it had already borrowed and abort the process. Freeing memory is
|
||||||
|
/// the wrong moment to be brittle about re-entry.
|
||||||
|
pub fn relieve(level: Level) {
|
||||||
|
let taken: Vec<(Tier, Box<dyn Fn()>)> = SINKS.with_borrow_mut(std::mem::take);
|
||||||
|
let mut run = 0usize;
|
||||||
|
for tier in Tier::ORDER {
|
||||||
|
if !tier.evicted_at(level) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (t, sink) in &taken {
|
||||||
|
if *t == tier {
|
||||||
|
sink();
|
||||||
|
run += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Put them back, keeping anything a sink registered while it ran — after,
|
||||||
|
// so the order within a tier stays registration order.
|
||||||
|
SINKS.with_borrow_mut(|sinks| {
|
||||||
|
let added = std::mem::replace(sinks, taken);
|
||||||
|
sinks.extend(added);
|
||||||
|
});
|
||||||
|
log::info!("memory pressure ({level:?}): ran {run} eviction(s)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
/// Registers one sink per tier, backwards, and hands back what they saw.
|
||||||
|
fn recorder() -> Rc<RefCell<Vec<Tier>>> {
|
||||||
|
let seen = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
for tier in [Tier::Thumbnails, Tier::Proxies, Tier::Gpu] {
|
||||||
|
let seen = seen.clone();
|
||||||
|
evict_at(tier, move || seen.borrow_mut().push(tier));
|
||||||
|
}
|
||||||
|
seen
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset() {
|
||||||
|
SINKS.with_borrow_mut(|s| s.clear());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eviction_runs_cheapest_to_rebuild_first() {
|
||||||
|
// Registered deliberately backwards, because the guarantee is about
|
||||||
|
// the tier and not about who registered first. A handler that simply
|
||||||
|
// ran its list would pass every other assertion here and fail this
|
||||||
|
// one — and on a device it would throw away thumbnails to keep a
|
||||||
|
// render pipeline that nothing was going to draw.
|
||||||
|
reset();
|
||||||
|
let seen = recorder();
|
||||||
|
relieve(Level::Critical);
|
||||||
|
assert_eq!(
|
||||||
|
*seen.borrow(),
|
||||||
|
vec![Tier::Gpu, Tier::Proxies, Tier::Thumbnails]
|
||||||
|
);
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hiding_the_window_costs_only_the_gpu() {
|
||||||
|
// The cheap moment: give back what a window that is not on screen
|
||||||
|
// cannot use, and keep what the user will be looking at when they come
|
||||||
|
// back. Widening this to everything would make every task switch a
|
||||||
|
// reload of the grid.
|
||||||
|
reset();
|
||||||
|
let seen = recorder();
|
||||||
|
relieve(Level::UiHidden);
|
||||||
|
assert_eq!(*seen.borrow(), vec![Tier::Gpu]);
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pressure_before_anything_is_registered_is_not_a_failure() {
|
||||||
|
// The launch window: `android_main` installs the listener before
|
||||||
|
// `run` builds a single cache, so the first minutes of a cold start
|
||||||
|
// can deliver a warning to an empty registry.
|
||||||
|
reset();
|
||||||
|
relieve(Level::Critical);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_sink_may_register_another_without_deadlocking() {
|
||||||
|
// Guards the re-entry the take-and-restore exists for: a sink is
|
||||||
|
// interface code, and interface code that reached this module again
|
||||||
|
// would otherwise meet a borrow it already held.
|
||||||
|
reset();
|
||||||
|
let seen = Rc::new(RefCell::new(0usize));
|
||||||
|
{
|
||||||
|
let seen = seen.clone();
|
||||||
|
evict_at(Tier::Gpu, move || {
|
||||||
|
*seen.borrow_mut() += 1;
|
||||||
|
evict_at(Tier::Thumbnails, || {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
relieve(Level::Critical);
|
||||||
|
assert_eq!(*seen.borrow(), 1);
|
||||||
|
// And the one it added survived, rather than being dropped with the
|
||||||
|
// temporary list.
|
||||||
|
assert_eq!(SINKS.with_borrow(|sinks| sinks.len()), 2);
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
//! TRACES: FR-CULL-3
|
||||||
|
//! The focus-peaking vocabulary, as the indices a chip row can carry.
|
||||||
|
//!
|
||||||
|
//! `dr_gpu` decides what peaking *is* — the measure, the thresholds, the
|
||||||
|
//! marks. This decides how a menu of three sensitivities and four colours
|
||||||
|
//! crosses the boundary into Slint, which has no notion of a Rust enum and
|
||||||
|
//! carries the choice as an `int` into an array of labels.
|
||||||
|
//!
|
||||||
|
//! That translation is small and it is the kind of small that goes wrong
|
||||||
|
//! silently. An index the interface sends that Rust reads as a different
|
||||||
|
//! variant produces a control that changes something other than what it says,
|
||||||
|
//! which nobody notices as a bug — they notice it as peaking behaving oddly.
|
||||||
|
//! So the order lives in one place here, both directions are asserted to round
|
||||||
|
//! trip, and a test checks that the labels in `ui/peaking.slint` still number
|
||||||
|
//! the same as the vocabularies they claim to name.
|
||||||
|
//!
|
||||||
|
//! Free-standing functions over plain integers, deliberately, for the reason
|
||||||
|
//! `crate::histogram` gives: none of this needs a GPU, a window or a
|
||||||
|
//! photograph to be checked, and all of it is invisible when wrong.
|
||||||
|
|
||||||
|
use dr_gpu::{PeakColour, PeakSensitivity};
|
||||||
|
|
||||||
|
/// The sensitivities, in the order the chip row shows them.
|
||||||
|
///
|
||||||
|
/// Least sensitive first, so the row reads left to right as "mark less" to
|
||||||
|
/// "mark more" — the axis the photographer is actually moving along.
|
||||||
|
pub(crate) const SENSITIVITIES: [PeakSensitivity; 3] = [
|
||||||
|
PeakSensitivity::Low,
|
||||||
|
PeakSensitivity::Medium,
|
||||||
|
PeakSensitivity::High,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The mark colours, in the order the chip row shows them.
|
||||||
|
pub(crate) const COLOURS: [PeakColour; 4] = [
|
||||||
|
PeakColour::Red,
|
||||||
|
PeakColour::Yellow,
|
||||||
|
PeakColour::Cyan,
|
||||||
|
PeakColour::Magenta,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The sensitivity an index names.
|
||||||
|
///
|
||||||
|
/// Out of range falls back to the default rather than panicking. The index
|
||||||
|
/// arrives from the interface, and the interface is the half of this that can
|
||||||
|
/// be recompiled without recompiling the other — a chip row that grew an entry
|
||||||
|
/// should degrade to a sane setting, not take the application down mid-cull.
|
||||||
|
pub(crate) fn sensitivity(index: i32) -> PeakSensitivity {
|
||||||
|
usize::try_from(index)
|
||||||
|
.ok()
|
||||||
|
.and_then(|i| SENSITIVITIES.get(i).copied())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The colour an index names, on the same terms.
|
||||||
|
pub(crate) fn colour(index: i32) -> PeakColour {
|
||||||
|
usize::try_from(index)
|
||||||
|
.ok()
|
||||||
|
.and_then(|i| COLOURS.get(i).copied())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which chip is lit for this sensitivity.
|
||||||
|
pub(crate) fn sensitivity_index(value: PeakSensitivity) -> i32 {
|
||||||
|
SENSITIVITIES.iter().position(|s| *s == value).unwrap_or(0) as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which chip is lit for this colour.
|
||||||
|
pub(crate) fn colour_index(value: PeakColour) -> i32 {
|
||||||
|
COLOURS.iter().position(|c| *c == value).unwrap_or(0) as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_variant_appears_exactly_once_in_its_row() {
|
||||||
|
// A variant missing from the row is a setting the photographer cannot
|
||||||
|
// reach; one listed twice is two chips that do the same thing, of
|
||||||
|
// which only the first can ever look selected. Both are invisible in
|
||||||
|
// the running application until somebody presses the wrong chip.
|
||||||
|
for s in SENSITIVITIES {
|
||||||
|
assert_eq!(
|
||||||
|
SENSITIVITIES.iter().filter(|x| **x == s).count(),
|
||||||
|
1,
|
||||||
|
"{s:?} is listed more than once"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for c in COLOURS {
|
||||||
|
assert_eq!(COLOURS.iter().filter(|x| **x == c).count(), 1);
|
||||||
|
}
|
||||||
|
// Named rather than counted, so adding a variant to `dr_gpu` without
|
||||||
|
// adding it here fails to compile instead of passing quietly.
|
||||||
|
assert!(SENSITIVITIES.contains(&PeakSensitivity::Low));
|
||||||
|
assert!(SENSITIVITIES.contains(&PeakSensitivity::Medium));
|
||||||
|
assert!(SENSITIVITIES.contains(&PeakSensitivity::High));
|
||||||
|
assert!(COLOURS.contains(&PeakColour::Red));
|
||||||
|
assert!(COLOURS.contains(&PeakColour::Yellow));
|
||||||
|
assert!(COLOURS.contains(&PeakColour::Cyan));
|
||||||
|
assert!(COLOURS.contains(&PeakColour::Magenta));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_index_and_its_variant_agree_in_both_directions() {
|
||||||
|
// The failure this catches is a chip that lights up under the pointer
|
||||||
|
// while a different setting takes effect — the two directions drifting
|
||||||
|
// apart is exactly what one shared array is here to prevent, and the
|
||||||
|
// only way to see it is to go round.
|
||||||
|
for (i, s) in SENSITIVITIES.iter().enumerate() {
|
||||||
|
assert_eq!(sensitivity(i as i32), *s);
|
||||||
|
assert_eq!(sensitivity_index(*s), i as i32);
|
||||||
|
}
|
||||||
|
for (i, c) in COLOURS.iter().enumerate() {
|
||||||
|
assert_eq!(colour(i as i32), *c);
|
||||||
|
assert_eq!(colour_index(*c), i as i32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_index_from_nowhere_lands_on_the_default_rather_than_panicking() {
|
||||||
|
// Slint has no bound on the `int` it sends and Rust has no way to
|
||||||
|
// refuse one. A panic here would be an application that closes because
|
||||||
|
// a chip row was edited.
|
||||||
|
assert_eq!(sensitivity(-1), PeakSensitivity::default());
|
||||||
|
assert_eq!(sensitivity(99), PeakSensitivity::default());
|
||||||
|
assert_eq!(colour(-1), PeakColour::default());
|
||||||
|
assert_eq!(colour(99), PeakColour::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_panel_offers_exactly_the_choices_this_module_knows_about() {
|
||||||
|
// **The one seam neither compiler checks.** The labels live in
|
||||||
|
// `ui/peaking.slint` and the meanings live here, joined only by an
|
||||||
|
// integer; a fifth colour added to the chip row would send index 4 to
|
||||||
|
// `colour`, which would quietly answer Red. Reading the file is
|
||||||
|
// clumsier than a derive, and it is what there is.
|
||||||
|
let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/peaking.slint"))
|
||||||
|
.expect("the panel this module serves");
|
||||||
|
|
||||||
|
let listed = |line_start: &str| -> usize {
|
||||||
|
let line = src
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.find(|l| l.starts_with(line_start))
|
||||||
|
.unwrap_or_else(|| panic!("no `{line_start}` row in peaking.slint"));
|
||||||
|
line.matches('"').count() / 2
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
listed("options: [\"Low\""),
|
||||||
|
SENSITIVITIES.len(),
|
||||||
|
"the sensitivity chips and `SENSITIVITIES` disagree"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
listed("options: [\"Red\""),
|
||||||
|
COLOURS.len(),
|
||||||
|
"the colour chips and `COLOURS` disagree"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
//! TRACES: FR-DEV-6 | FR-PLAT-LIN-1
|
||||||
|
//! Reads and writes the named preset library beside the other config.
|
||||||
|
//!
|
||||||
|
//! A near-twin of [`SettingsStore`](crate::settings_store::SettingsStore), and
|
||||||
|
//! separate from it for the reason that one is: two files, two lifetimes.
|
||||||
|
//! Resetting preferences must not destroy a photographer's presets, and a
|
||||||
|
//! preset library is the one file here that represents work rather than
|
||||||
|
//! configuration — it is what someone would carry to another machine.
|
||||||
|
//!
|
||||||
|
//! # Why the library is loaded whole and saved whole
|
||||||
|
//!
|
||||||
|
//! There is no incremental path. The document is kilobytes (see
|
||||||
|
//! [`PresetLibrary`]), the caller is holding the copy the user just edited,
|
||||||
|
//! and a merge would let a preset the window has not drawn yet resurrect
|
||||||
|
//! after a delete. The same argument the settings store makes about fields,
|
||||||
|
//! made about entries.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use dr_pipeline::PresetLibrary;
|
||||||
|
|
||||||
|
/// Loads and saves the named preset library.
|
||||||
|
pub struct PresetStore {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PresetStore {
|
||||||
|
/// Open the store at the platform config location.
|
||||||
|
///
|
||||||
|
/// Linux: `$XDG_CONFIG_HOME/darkroom/presets.drpl`, falling back to
|
||||||
|
/// `~/.config` — the same resolution `SettingsStore` does, so the files sit
|
||||||
|
/// together and a user backing up one takes all of them.
|
||||||
|
pub fn open() -> Self {
|
||||||
|
let dir = std::env::var_os("XDG_CONFIG_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config")
|
||||||
|
})
|
||||||
|
.join("darkroom");
|
||||||
|
Self::open_at(dir.join(format!(
|
||||||
|
"presets.{}",
|
||||||
|
dr_pipeline::preset::LIBRARY_EXTENSION
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open at an explicit path — for tests, and for a non-default location.
|
||||||
|
pub fn open_at(path: PathBuf) -> Self {
|
||||||
|
Self { path }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored library, or an empty one.
|
||||||
|
///
|
||||||
|
/// A missing file is a first run. An unparseable one is answered with an
|
||||||
|
/// empty library rather than an error, on the same reasoning the settings
|
||||||
|
/// store gives — the alternative is an app that will not start until the
|
||||||
|
/// user hand-edits a file.
|
||||||
|
///
|
||||||
|
/// The difference worth stating: settings are regenerated on the next
|
||||||
|
/// save, where a preset library is *work*, and rewriting it whole would
|
||||||
|
/// destroy whatever was in there. So a library that failed to parse is
|
||||||
|
/// held empty in memory and **not** written back over until the user saves
|
||||||
|
/// a preset, at which point they have chosen to. Nothing here deletes the
|
||||||
|
/// file, and the warning names the path so it can be recovered by hand.
|
||||||
|
pub fn load(&self) -> PresetLibrary {
|
||||||
|
match std::fs::read_to_string(&self.path) {
|
||||||
|
Ok(text) => match PresetLibrary::parse(&text) {
|
||||||
|
Ok(library) => library,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"{} is not a readable preset library ({e}); \
|
||||||
|
starting empty, the file is left alone",
|
||||||
|
self.path.display()
|
||||||
|
);
|
||||||
|
PresetLibrary::default()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => PresetLibrary::default(),
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("reading {}: {e}; starting empty", self.path.display());
|
||||||
|
PresetLibrary::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the library, replacing whatever was there.
|
||||||
|
pub fn save(&self, library: &PresetLibrary) -> Result<(), PresetStoreError> {
|
||||||
|
if let Some(parent) = self.path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write and rename, so an interrupted save cannot truncate the
|
||||||
|
// existing file — the same discipline the settings and session stores
|
||||||
|
// use, and it matters more here because what would be truncated is
|
||||||
|
// every preset the user has ever made rather than a set of
|
||||||
|
// preferences that rebuild themselves.
|
||||||
|
let tmp = self.path.with_extension("tmp");
|
||||||
|
std::fs::write(&tmp, library.to_text())?;
|
||||||
|
std::fs::rename(&tmp, &self.path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum PresetStoreError {
|
||||||
|
#[error("preset library io: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use dr_pipeline::Preset;
|
||||||
|
|
||||||
|
/// The same hand-rolled temp directory the settings store's tests use —
|
||||||
|
/// unique per process and thread, so a parallel run cannot collide.
|
||||||
|
fn tempdir(name: &str) -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"dr-presets-test-{name}-{}-{:?}",
|
||||||
|
std::process::id(),
|
||||||
|
std::thread::current().id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store(name: &str) -> (PresetStore, PathBuf) {
|
||||||
|
let dir = tempdir(name);
|
||||||
|
(
|
||||||
|
PresetStore::open_at(dir.join("nested").join("presets.drpl")),
|
||||||
|
dir,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn library() -> PresetLibrary {
|
||||||
|
let mut lib = PresetLibrary::default();
|
||||||
|
let mut params = std::collections::BTreeMap::new();
|
||||||
|
params.insert(("exposure".to_string(), "exposure".to_string()), 0.75);
|
||||||
|
lib.insert("Warm", Preset::from_params(params)).unwrap();
|
||||||
|
lib.insert("Neutral", Preset::default()).unwrap();
|
||||||
|
lib
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_library_round_trips_through_the_store() {
|
||||||
|
let (store, _dir) = store("a-library-round-trips-through-the-store");
|
||||||
|
store.save(&library()).unwrap();
|
||||||
|
assert_eq!(store.load(), library());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_first_run_loads_an_empty_library() {
|
||||||
|
let (store, _dir) = store("a-first-run-loads-an-empty-library");
|
||||||
|
assert!(!store.path().exists());
|
||||||
|
assert!(store.load().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn saving_creates_the_config_directory() {
|
||||||
|
let (store, _dir) = store("saving-creates-the-config-directory");
|
||||||
|
store.save(&library()).unwrap();
|
||||||
|
assert!(store.path().exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_file_is_left_alone_rather_than_overwritten() {
|
||||||
|
// The difference from the settings store, and the reason this test
|
||||||
|
// exists: what is on disk is work, so a parse failure must not be the
|
||||||
|
// moment it is destroyed.
|
||||||
|
let (store, _dir) = store("an-unreadable-file-is-left-alone-rather-than-overwritten");
|
||||||
|
std::fs::create_dir_all(store.path().parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(store.path(), "this is not a preset library").unwrap();
|
||||||
|
|
||||||
|
assert!(store.load().is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(store.path()).unwrap(),
|
||||||
|
"this is not a preset library"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_neutral_preset_survives_a_save_and_load() {
|
||||||
|
// It is a real entry, not an absence — see `PresetLibrary::insert`.
|
||||||
|
let (store, _dir) = store("a-neutral-preset-survives-a-save-and-load");
|
||||||
|
store.save(&library()).unwrap();
|
||||||
|
assert_eq!(store.load().get("Neutral"), Some(&Preset::default()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+417
-2
@@ -35,10 +35,11 @@ use std::cell::RefCell;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use dr_pipeline::{Preset, Scope, Sidecar};
|
use dr_pipeline::{NameError, Preset, PresetLibrary, Scope, Sidecar};
|
||||||
use slint::ComponentHandle;
|
use slint::ComponentHandle;
|
||||||
|
|
||||||
use crate::develop::DevelopSession;
|
use crate::develop::DevelopSession;
|
||||||
|
use crate::preset_store::PresetStore;
|
||||||
use crate::{library, library_ui, settings_ui, AppWindow, ParamRow};
|
use crate::{library, library_ui, settings_ui, AppWindow, ParamRow};
|
||||||
|
|
||||||
/// Where the develop view's current edit is stored.
|
/// Where the develop view's current edit is stored.
|
||||||
@@ -121,13 +122,23 @@ impl Clipboard {
|
|||||||
let Some(preset) = self.preset.borrow().clone() else {
|
let Some(preset) = self.preset.borrow().clone() else {
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
|
describe(&preset, scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A short description of a preset's contents, for a label.
|
||||||
|
///
|
||||||
|
/// Free rather than a method on [`Clipboard`] because the named-preset sheet
|
||||||
|
/// describes what *saving* would capture, and a second phrasing of the same
|
||||||
|
/// count is a second thing to keep in step — the sheet saying "3 settings"
|
||||||
|
/// beside a panel saying "3 adjustments" would read as two different numbers.
|
||||||
|
pub fn describe(preset: &Preset, scope: Scope) -> String {
|
||||||
match preset.op_count(scope) {
|
match preset.op_count(scope) {
|
||||||
0 => "Neutral".to_string(),
|
0 => "Neutral".to_string(),
|
||||||
1 => "1 adjustment".to_string(),
|
1 => "1 adjustment".to_string(),
|
||||||
n => format!("{n} adjustments"),
|
n => format!("{n} adjustments"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// The scope a paste should use, from the user's preference.
|
/// The scope a paste should use, from the user's preference.
|
||||||
///
|
///
|
||||||
@@ -528,6 +539,308 @@ pub fn wire(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Named presets (FR-DEV-6)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
/// The saved preset library, and the file it lives in.
|
||||||
|
///
|
||||||
|
/// # Why the library is held in memory as well as on disk
|
||||||
|
///
|
||||||
|
/// Every change writes the whole file (see [`PresetStore`]), so the in-memory
|
||||||
|
/// copy is what the sheet is drawn from and what the next edit is applied to.
|
||||||
|
/// Reading the file back after each change would be the same bytes and one
|
||||||
|
/// more chance for a failed read to empty a list the user is looking at.
|
||||||
|
///
|
||||||
|
/// # A failed save is reported, not swallowed
|
||||||
|
///
|
||||||
|
/// The clipboard cannot fail — it is memory. This can: a full disk, a config
|
||||||
|
/// directory that is not writable. Losing a preset the user just named, with
|
||||||
|
/// the sheet cheerfully listing it, would be discovered at the worst possible
|
||||||
|
/// moment, so the write's result reaches the window.
|
||||||
|
pub struct NamedPresets {
|
||||||
|
store: PresetStore,
|
||||||
|
library: RefCell<PresetLibrary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NamedPresets {
|
||||||
|
/// Load the library from its usual place.
|
||||||
|
pub fn open() -> Rc<Self> {
|
||||||
|
Self::at(PresetStore::open())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load from an explicit store — for tests, and for a non-default location.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn open_at(path: PathBuf) -> Rc<Self> {
|
||||||
|
Self::at(PresetStore::open_at(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn at(store: PresetStore) -> Rc<Self> {
|
||||||
|
let library = RefCell::new(store.load());
|
||||||
|
Rc::new(Self { store, library })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored names, in the order they are written.
|
||||||
|
pub fn names(&self) -> Vec<String> {
|
||||||
|
self.library.borrow().names().map(String::from).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The preset stored under `name`.
|
||||||
|
pub fn get(&self, name: &str) -> Option<Preset> {
|
||||||
|
self.library.borrow().get(name).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store `preset` under `name` and persist.
|
||||||
|
///
|
||||||
|
/// The in-memory library is updated first and rolled back if the write
|
||||||
|
/// fails, so what the sheet lists is always what is on disk. The
|
||||||
|
/// alternative — writing first — would mean holding a preset the file does
|
||||||
|
/// not have on every failure path.
|
||||||
|
fn insert(&self, name: &str, preset: Preset) -> Result<(), SaveError> {
|
||||||
|
let previous = {
|
||||||
|
let mut library = self.library.borrow_mut();
|
||||||
|
let existing = library.get(name.trim()).cloned();
|
||||||
|
library.insert(name, preset).map_err(SaveError::Name)?;
|
||||||
|
existing
|
||||||
|
};
|
||||||
|
self.persist(|library| match previous {
|
||||||
|
Some(p) => {
|
||||||
|
let _ = library.insert(name, p);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
library.remove(name.trim());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the library, undoing the in-memory change if the write fails.
|
||||||
|
fn persist(&self, rollback: impl FnOnce(&mut PresetLibrary)) -> Result<(), SaveError> {
|
||||||
|
let result = self.store.save(&self.library.borrow());
|
||||||
|
match result {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) => {
|
||||||
|
rollback(&mut self.library.borrow_mut());
|
||||||
|
// Named, the way the settings page names its file: "could not
|
||||||
|
// save" without saying where leaves the user nothing to check
|
||||||
|
// and nothing to fix.
|
||||||
|
Err(SaveError::Write(format!(
|
||||||
|
"{}: {e}",
|
||||||
|
self.store.path().display()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a preset could not be saved.
|
||||||
|
enum SaveError {
|
||||||
|
/// The name itself was refused.
|
||||||
|
Name(NameError),
|
||||||
|
/// The library could not be written.
|
||||||
|
Write(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SaveError {
|
||||||
|
/// What to put in front of the user.
|
||||||
|
///
|
||||||
|
/// The prose lives here rather than in `dr-pipeline`, which depends on
|
||||||
|
/// nothing and has no business holding user-facing strings.
|
||||||
|
fn message(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Name(NameError::Empty) => "Give the preset a name.".to_string(),
|
||||||
|
Self::Name(NameError::Unrepresentable) => {
|
||||||
|
"A preset name cannot contain brackets or line breaks.".to_string()
|
||||||
|
}
|
||||||
|
Self::Write(e) => format!("Could not save presets: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push the stored names onto the window.
|
||||||
|
pub fn render_named(window: &AppWindow, named: &Rc<NamedPresets>) {
|
||||||
|
let names: Vec<slint::SharedString> = named.names().into_iter().map(Into::into).collect();
|
||||||
|
window.set_preset_names(slint::ModelRc::new(slint::VecModel::from(names)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire saving, applying, renaming and deleting named presets.
|
||||||
|
///
|
||||||
|
/// Takes the same [`Develop`] bundle the clipboard wiring does, and for the
|
||||||
|
/// same reason: applying a preset to the open image changes the graph, so it
|
||||||
|
/// has to rebuild the panel, redraw the canvas and know where to save.
|
||||||
|
pub fn wire_named(
|
||||||
|
window: &AppWindow,
|
||||||
|
named: Rc<NamedPresets>,
|
||||||
|
develop: Develop,
|
||||||
|
settings: Rc<settings_ui::SettingsController>,
|
||||||
|
library: Rc<library_ui::LibraryController>,
|
||||||
|
collections: Rc<crate::collections_ui::CollectionsController>,
|
||||||
|
) {
|
||||||
|
let Develop {
|
||||||
|
session,
|
||||||
|
rows,
|
||||||
|
redraw,
|
||||||
|
open,
|
||||||
|
} = develop;
|
||||||
|
|
||||||
|
render_named(window, &named);
|
||||||
|
|
||||||
|
// --- save the open edit under a name ---------------------------------
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let named = named.clone();
|
||||||
|
let session = session.clone();
|
||||||
|
window.on_save_preset(move |name| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let Some(preset) = session.borrow().as_ref().map(|s| s.copy_settings()) else {
|
||||||
|
w.set_preset_name_error("Open a photograph first.".into());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Captured at full scope, exactly as a copy is: the scope is a
|
||||||
|
// decision about applying, and a preset that had already discarded
|
||||||
|
// the crop could never grow it back (see `Preset::capture`).
|
||||||
|
match named.insert(&name, preset) {
|
||||||
|
Ok(()) => {
|
||||||
|
w.set_preset_name_error(Default::default());
|
||||||
|
render_named(&w, &named);
|
||||||
|
}
|
||||||
|
Err(e) => w.set_preset_name_error(e.message().into()),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal the user has started correcting is stale, and a message that
|
||||||
|
// outlives its cause is one the user learns to ignore.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
window.on_preset_name_edited(move |_| {
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
w.set_preset_name_error(Default::default());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- what saving would capture ----------------------------------------
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
window.on_presets_opened(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
// At the scope a save would use, which is full: a preset keeps
|
||||||
|
// the framing it was captured with and drops it at apply time.
|
||||||
|
let summary = session
|
||||||
|
.borrow()
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| describe(&s.copy_settings(), Scope::Everything))
|
||||||
|
.unwrap_or_default();
|
||||||
|
w.set_preset_capture_summary(summary.into());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- apply ------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// One callback for both targets. Which one is meant is not a guess: the
|
||||||
|
// sheet was opened from a view that set `preset-apply-count`, and the
|
||||||
|
// label the user just read said "Applies to 12 selected photographs" or
|
||||||
|
// said nothing. Deciding here from the same number keeps the promise.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let named = named.clone();
|
||||||
|
let settings = settings.clone();
|
||||||
|
let library = library.clone();
|
||||||
|
let collections = collections.clone();
|
||||||
|
let session = session.clone();
|
||||||
|
let rows = rows.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
let open = open.clone();
|
||||||
|
window.on_apply_preset(move |name| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let Some(preset) = named.get(&name) else {
|
||||||
|
// Another window may have deleted it since this list was drawn.
|
||||||
|
render_named(&w, &named);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let scope = scope_for(&settings.snapshot());
|
||||||
|
|
||||||
|
if w.get_preset_apply_count() > 0 {
|
||||||
|
library_ui::paste_settings_to_selection(
|
||||||
|
&w,
|
||||||
|
&library,
|
||||||
|
&collections.selected(),
|
||||||
|
&preset,
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
{
|
||||||
|
let mut slot = session.borrow_mut();
|
||||||
|
let Some(s) = slot.as_mut() else { return };
|
||||||
|
s.apply_settings(&preset, scope);
|
||||||
|
}
|
||||||
|
// The same three steps a paste takes, for the same reasons:
|
||||||
|
// many controls moved without any of them being touched, and
|
||||||
|
// a deliberate discrete action is saved immediately.
|
||||||
|
crate::sync_rows(&w, &rows, &session);
|
||||||
|
redraw(&w);
|
||||||
|
save_open_edit(&w, &open.borrow(), &session, &library);
|
||||||
|
}
|
||||||
|
|
||||||
|
w.set_presets_open(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- rename -----------------------------------------------------------
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let named = named.clone();
|
||||||
|
window.on_rename_preset(move |from, to| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let renamed = {
|
||||||
|
let mut library = named.library.borrow_mut();
|
||||||
|
library.rename(&from, &to)
|
||||||
|
};
|
||||||
|
match renamed {
|
||||||
|
Ok(_) => {
|
||||||
|
// Nothing to roll back to on a failed write beyond the
|
||||||
|
// name it had, which is what this restores.
|
||||||
|
let from = from.to_string();
|
||||||
|
let to = to.to_string();
|
||||||
|
if let Err(e) = named.persist(move |library| {
|
||||||
|
let _ = library.rename(&to, &from);
|
||||||
|
}) {
|
||||||
|
w.set_preset_name_error(e.message().into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => w.set_preset_name_error(SaveError::Name(e).message().into()),
|
||||||
|
}
|
||||||
|
render_named(&w, &named);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- delete -----------------------------------------------------------
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let named = named.clone();
|
||||||
|
window.on_delete_preset(move |name| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let removed = {
|
||||||
|
let mut library = named.library.borrow_mut();
|
||||||
|
let previous = library.get(&name).cloned();
|
||||||
|
library.remove(&name);
|
||||||
|
previous
|
||||||
|
};
|
||||||
|
if let Some(previous) = removed {
|
||||||
|
let name = name.to_string();
|
||||||
|
if let Err(e) = named.persist(move |library| {
|
||||||
|
let _ = library.insert(&name, previous);
|
||||||
|
}) {
|
||||||
|
w.set_preset_name_error(e.message().into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
render_named(&w, &named);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Seconds since the epoch, or zero if the clock is before it.
|
/// Seconds since the epoch, or zero if the clock is before it.
|
||||||
///
|
///
|
||||||
/// Zero rather than a panic: a wrong timestamp costs a tie-break in the merge,
|
/// Zero rather than a panic: a wrong timestamp costs a tie-break in the merge,
|
||||||
@@ -804,4 +1117,106 @@ mod tests {
|
|||||||
assert!(clipboard.is_armed());
|
assert!(clipboard.is_armed());
|
||||||
assert_eq!(clipboard.describe(Scope::Adjustments), "Neutral");
|
assert_eq!(clipboard.describe(Scope::Adjustments), "Neutral");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Named presets
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn named(name: &str) -> (Rc<NamedPresets>, PathBuf) {
|
||||||
|
let dir = tempdir(name);
|
||||||
|
(NamedPresets::open_at(dir.join("presets.drpl")), dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_saved_preset_is_on_disk_before_the_call_returns() {
|
||||||
|
// Not on the way out, and not on a timer: a preset the user named and
|
||||||
|
// then lost to a crash is the one failure this feature cannot have.
|
||||||
|
let (presets, dir) = named("saved-immediately");
|
||||||
|
presets
|
||||||
|
.insert("Warm", Preset::capture(&edited()))
|
||||||
|
.ok()
|
||||||
|
.expect("saved");
|
||||||
|
|
||||||
|
let reloaded = NamedPresets::open_at(dir.join("presets.drpl"));
|
||||||
|
assert_eq!(reloaded.names(), vec!["Warm".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_saved_preset_carries_the_edit_it_captured() {
|
||||||
|
let (presets, _dir) = named("carries-the-edit");
|
||||||
|
presets.insert("Warm", Preset::capture(&edited())).ok();
|
||||||
|
|
||||||
|
let preset = presets.get("Warm").expect("stored");
|
||||||
|
let mut target = EditGraph::default_chain();
|
||||||
|
preset.apply(&mut target, Scope::Adjustments);
|
||||||
|
assert_eq!(
|
||||||
|
target.param(
|
||||||
|
dr_pipeline::ops::exposure::ID,
|
||||||
|
dr_pipeline::ops::exposure::EXPOSURE
|
||||||
|
),
|
||||||
|
Some(1.5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_name_that_cannot_be_stored_is_refused_rather_than_mangled() {
|
||||||
|
let (presets, _dir) = named("refused-name");
|
||||||
|
assert!(presets.insert("", Preset::default()).is_err());
|
||||||
|
assert!(presets.insert("bracket]", Preset::default()).is_err());
|
||||||
|
assert!(presets.names().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_write_that_fails_leaves_the_list_showing_what_is_on_disk() {
|
||||||
|
// The rollback. A sheet listing a preset the file does not have is a
|
||||||
|
// loss the user discovers later, at the moment they reach for it.
|
||||||
|
let dir = tempdir("failed-write");
|
||||||
|
// A *file* where the store wants a directory, so `create_dir_all`
|
||||||
|
// fails and the save cannot succeed.
|
||||||
|
let blocked = dir.join("blocked");
|
||||||
|
std::fs::write(&blocked, b"not a directory").unwrap();
|
||||||
|
|
||||||
|
let presets = NamedPresets::open_at(blocked.join("presets.drpl"));
|
||||||
|
assert!(presets.insert("Warm", Preset::default()).is_err());
|
||||||
|
assert!(
|
||||||
|
presets.names().is_empty(),
|
||||||
|
"the failed save left a preset behind"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failed_overwrite_puts_the_original_back() {
|
||||||
|
// The other half of the rollback, and the one that loses work if it is
|
||||||
|
// wrong: overwriting is destructive, so a failed overwrite has to
|
||||||
|
// restore what was there rather than leave the name holding the new
|
||||||
|
// value the file never received.
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let dir = tempdir("failed-overwrite");
|
||||||
|
let path = dir.join("presets.drpl");
|
||||||
|
let presets = NamedPresets::open_at(path.clone());
|
||||||
|
presets.insert("Warm", Preset::capture(&edited())).ok();
|
||||||
|
let original = presets.get("Warm").expect("stored");
|
||||||
|
|
||||||
|
// The directory exists, so `create_dir_all` still succeeds and it is
|
||||||
|
// the write of the temporary file that fails — which is the path a
|
||||||
|
// full disk takes.
|
||||||
|
let mut perms = std::fs::metadata(&dir).unwrap().permissions();
|
||||||
|
perms.set_mode(0o500);
|
||||||
|
std::fs::set_permissions(&dir, perms.clone()).unwrap();
|
||||||
|
|
||||||
|
let failed = presets.insert("Warm", Preset::default()).is_err();
|
||||||
|
|
||||||
|
// Restore the permissions before asserting, so a failure here does not
|
||||||
|
// leave an undeletable directory behind for the next run.
|
||||||
|
perms.set_mode(0o700);
|
||||||
|
std::fs::set_permissions(&dir, perms).unwrap();
|
||||||
|
|
||||||
|
assert!(failed, "the write should have failed");
|
||||||
|
assert_eq!(
|
||||||
|
presets.get("Warm"),
|
||||||
|
Some(original),
|
||||||
|
"the failed overwrite kept the new value"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! TRACES: FR-PLAT-LIN-1 | FR-NC-6a | FR-EXP-5
|
//! TRACES: FR-PLAT-LIN-1 | FR-NC-6a | FR-EXP-5 | NFR-OPS-3
|
||||||
//! Reads and writes `settings.json` beside the session config.
|
//! Reads and writes `settings.json` beside the session config.
|
||||||
//!
|
//!
|
||||||
//! Deliberately a near-twin of [`SessionStore`](dr_sync_nextcloud::SessionStore)
|
//! Deliberately a near-twin of [`SessionStore`](dr_sync_nextcloud::SessionStore)
|
||||||
|
|||||||
@@ -733,6 +733,10 @@ export component TransferPanel inherits VerticalLayout {
|
|||||||
|
|
||||||
callback copy();
|
callback copy();
|
||||||
callback paste();
|
callback paste();
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
/// Open the named-preset sheet. Beside copy and paste because it is the
|
||||||
|
/// same thought given a name — this edit, kept.
|
||||||
|
callback open-presets();
|
||||||
|
|
||||||
/// TRACES: FR-UI-2
|
/// TRACES: FR-UI-2
|
||||||
/// How wide this panel has to be before it starts clipping itself — the
|
/// How wide this panel has to be before it starts clipping itself — the
|
||||||
@@ -772,6 +776,16 @@ export component TransferPanel inherits VerticalLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-DEV-6
|
||||||
|
// The saved half. On its own row rather than a third of the one above,
|
||||||
|
// because copy and paste are a pair — one arms the other — and a button
|
||||||
|
// that does neither sitting between them would read as part of that pair.
|
||||||
|
Button {
|
||||||
|
text: "Presets…";
|
||||||
|
enabled: root.enabled;
|
||||||
|
clicked => { root.open-presets(); }
|
||||||
|
}
|
||||||
|
|
||||||
if root.armed: Caption {
|
if root.armed: Caption {
|
||||||
text: root.summary + (root.framing-withheld ? " · crop not included" : "");
|
text: root.summary + (root.framing-withheld ? " · crop not included" : "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChi
|
|||||||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
||||||
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
|
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
|
||||||
import { HistogramPanel, HistogramView } from "histogram.slint";
|
import { HistogramPanel, HistogramView } from "histogram.slint";
|
||||||
|
import { PresetSheet } from "presets.slint";
|
||||||
|
import { FocusMarks, FocusPanel } from "peaking.slint";
|
||||||
import { SettingsPage } from "settings.slint";
|
import { SettingsPage } from "settings.slint";
|
||||||
import { ImportPage } from "import.slint";
|
import { ImportPage } from "import.slint";
|
||||||
import { StatusBar, InfoPanel } from "develop.slint";
|
import { StatusBar, InfoPanel } from "develop.slint";
|
||||||
@@ -70,6 +72,22 @@ export component AppWindow inherits Window {
|
|||||||
/// of a draft frame is a histogram of an image nobody is reading.
|
/// of a draft frame is a histogram of an image nobody is reading.
|
||||||
in property <HistogramView> histogram;
|
in property <HistogramView> histogram;
|
||||||
|
|
||||||
|
/// TRACES: FR-CULL-3
|
||||||
|
/// Focus peaking: the marks, whether they describe *this* frame, and the
|
||||||
|
/// three things the photographer chose. All of them are Rust's, because
|
||||||
|
/// the marks come from a compute pass — see `peaking.slint` for why
|
||||||
|
/// `focus-overlay-ready` is a separate question from `peaking-on`.
|
||||||
|
in property <image> focus-overlay;
|
||||||
|
in property <bool> focus-overlay-ready: false;
|
||||||
|
in property <bool> peaking-on: false;
|
||||||
|
in property <bool> peaking-available: true;
|
||||||
|
in property <int> peaking-sensitivity: 1;
|
||||||
|
in property <int> peaking-colour: 0;
|
||||||
|
|
||||||
|
callback peaking-toggled(bool);
|
||||||
|
callback peaking-sensitivity-picked(int);
|
||||||
|
callback peaking-colour-picked(int);
|
||||||
|
|
||||||
// --- zoom, pan and crop (FR-DEV-4) ---
|
// --- zoom, pan and crop (FR-DEV-4) ---
|
||||||
//
|
//
|
||||||
// Zoom is a *viewing* state, not an edit: it changes the resolution the
|
// Zoom is a *viewing* state, not an edit: it changes the resolution the
|
||||||
@@ -585,6 +603,8 @@ export component AppWindow inherits Window {
|
|||||||
callback library-cell-rated(int, int);
|
callback library-cell-rated(int, int);
|
||||||
/// The trash target was clicked on one cell, by row.
|
/// The trash target was clicked on one cell, by row.
|
||||||
callback library-cell-trashed(int);
|
callback library-cell-trashed(int);
|
||||||
|
/// The burst mark was clicked on one cell, by row (FR-CULL-5).
|
||||||
|
callback library-burst-toggled(int);
|
||||||
/// Move the grid selection to the trash — the `Delete` key.
|
/// Move the grid selection to the trash — the `Delete` key.
|
||||||
callback library-trash-selection();
|
callback library-trash-selection();
|
||||||
/// A judgement key was pressed, applying to the whole selection. One of
|
/// A judgement key was pressed, applying to the whole selection. One of
|
||||||
@@ -661,6 +681,35 @@ export component AppWindow inherits Window {
|
|||||||
/// Apply the clipboard to every selected image in the grid.
|
/// Apply the clipboard to every selected image in the grid.
|
||||||
callback paste-settings-to-selection();
|
callback paste-settings-to-selection();
|
||||||
|
|
||||||
|
// --- named presets (FR-DEV-6) ---
|
||||||
|
//
|
||||||
|
// The saved half of the same requirement. On the window rather than in a
|
||||||
|
// view for the reason the clipboard is: a preset is saved in develop,
|
||||||
|
// where there is an edit to capture, and applied most often in the grid,
|
||||||
|
// where there is a selection to apply it to.
|
||||||
|
in-out property <bool> presets-open: false;
|
||||||
|
in property <[string]> preset-names;
|
||||||
|
/// Whether there is an edit in hand to save, set by whichever view opened
|
||||||
|
/// the sheet. `in-out` because that is where the answer is known.
|
||||||
|
in-out property <bool> preset-can-save: false;
|
||||||
|
/// What applying would act on: 0 is the open photograph, higher is that
|
||||||
|
/// many selected. Set at open, and read back by Rust when one is picked —
|
||||||
|
/// so the action matches the count the user read on the way in.
|
||||||
|
in-out property <int> preset-apply-count: 0;
|
||||||
|
/// What saving would capture, from the routine the clipboard summary uses.
|
||||||
|
in property <string> preset-capture-summary;
|
||||||
|
/// Why the last name was refused, cleared by the next keystroke.
|
||||||
|
in property <string> preset-name-error;
|
||||||
|
callback save-preset(string);
|
||||||
|
callback apply-preset(string);
|
||||||
|
callback rename-preset(string, string);
|
||||||
|
callback delete-preset(string);
|
||||||
|
callback preset-name-edited(string);
|
||||||
|
/// Raised as the sheet opens over an open photograph, so Rust can say what
|
||||||
|
/// saving would capture. A property refreshed on every slider drag would
|
||||||
|
/// be recomputing a string nobody is looking at.
|
||||||
|
callback presets-opened();
|
||||||
|
|
||||||
// --- settings (FR-EXP-1, FR-EXP-3, FR-NC-6a) ---
|
// --- settings (FR-EXP-1, FR-EXP-3, FR-NC-6a) ---
|
||||||
//
|
//
|
||||||
// A page rather than an overlay, and the outermost of the view conditions
|
// A page rather than an overlay, and the outermost of the view conditions
|
||||||
@@ -1444,6 +1493,15 @@ in property <bool> panel-visible: true;
|
|||||||
paste-settings-to-selection => {
|
paste-settings-to-selection => {
|
||||||
root.paste-settings-to-selection();
|
root.paste-settings-to-selection();
|
||||||
}
|
}
|
||||||
|
// TRACES: FR-DEV-6
|
||||||
|
// Opened with the selection's size, which is both what the
|
||||||
|
// 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-can-save = false;
|
||||||
|
root.presets-open = true;
|
||||||
|
}
|
||||||
|
|
||||||
// TRACES: FR-EXP-7
|
// TRACES: FR-EXP-7
|
||||||
exporting: root.library-exporting;
|
exporting: root.library-exporting;
|
||||||
@@ -1527,6 +1585,7 @@ in property <bool> panel-visible: true;
|
|||||||
|
|
||||||
cell-rated(i, n) => { root.library-cell-rated(i, n); }
|
cell-rated(i, n) => { root.library-cell-rated(i, n); }
|
||||||
cell-trashed(i) => { root.library-cell-trashed(i); }
|
cell-trashed(i) => { root.library-cell-trashed(i); }
|
||||||
|
burst-toggled(i) => { root.library-burst-toggled(i); }
|
||||||
trash-selection() => { root.library-trash-selection(); }
|
trash-selection() => { root.library-trash-selection(); }
|
||||||
// Derived from the sidebar's own selection rather than
|
// Derived from the sidebar's own selection rather than
|
||||||
// mirrored in a second property: `-1` is already the sentinel
|
// mirrored in a second property: `-1` is already the sentinel
|
||||||
@@ -1654,6 +1713,18 @@ in property <bool> panel-visible: true;
|
|||||||
image-rendering: ImageRendering.pixelated;
|
image-rendering: ImageRendering.pixelated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-CULL-3
|
||||||
|
// The focus marks, over the same fitted rect. See
|
||||||
|
// `peaking.slint` for why they are a layer over the canvas
|
||||||
|
// rather than a tint in it.
|
||||||
|
if root.focus-overlay-ready && root.total > 0: FocusMarks {
|
||||||
|
x: parent.shown-x;
|
||||||
|
y: parent.shown-y;
|
||||||
|
width: parent.shown-w;
|
||||||
|
height: parent.shown-h;
|
||||||
|
marks: root.focus-overlay;
|
||||||
|
}
|
||||||
|
|
||||||
// Where the photograph actually sits inside this box.
|
// Where the photograph actually sits inside this box.
|
||||||
//
|
//
|
||||||
// `image-fit: contain` letterboxes, and Slint does not report
|
// `image-fit: contain` letterboxes, and Slint does not report
|
||||||
@@ -2187,6 +2258,26 @@ in property <bool> panel-visible: true;
|
|||||||
background: Theme.rule;
|
background: Theme.rule;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-CULL-3
|
||||||
|
// Under the histogram, because the two are the same
|
||||||
|
// kind of thing: instruments that report on the
|
||||||
|
// photograph rather than change it. Kept in every
|
||||||
|
// mode for the same reason the histogram is.
|
||||||
|
FocusPanel {
|
||||||
|
available: root.peaking-available;
|
||||||
|
showing: root.peaking-on;
|
||||||
|
sensitivity: root.peaking-sensitivity;
|
||||||
|
colour: root.peaking-colour;
|
||||||
|
toggled(v) => { root.peaking-toggled(v); }
|
||||||
|
sensitivity-picked(i) => { root.peaking-sensitivity-picked(i); }
|
||||||
|
colour-picked(i) => { root.peaking-colour-picked(i); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
height: 1px;
|
||||||
|
background: Theme.rule;
|
||||||
|
}
|
||||||
|
|
||||||
// Framing above the colour work, matching how the edit is
|
// Framing above the colour work, matching how the edit is
|
||||||
// made rather than how it is applied: the frame is decided
|
// made rather than how it is applied: the frame is decided
|
||||||
// by eye first and the pipeline runs it last (see
|
// by eye first and the pipeline runs it last (see
|
||||||
@@ -2240,6 +2331,15 @@ in property <bool> panel-visible: true;
|
|||||||
framing-withheld: root.settings-framing-withheld;
|
framing-withheld: root.settings-framing-withheld;
|
||||||
copy => { root.copy-settings(); }
|
copy => { root.copy-settings(); }
|
||||||
paste => { root.paste-settings(); }
|
paste => { root.paste-settings(); }
|
||||||
|
// Opened with no count, which is what tells
|
||||||
|
// the apply handler this means the open
|
||||||
|
// photograph rather than a selection.
|
||||||
|
open-presets => {
|
||||||
|
root.preset-apply-count = 0;
|
||||||
|
root.preset-can-save = root.adjust-enabled;
|
||||||
|
root.presets-opened();
|
||||||
|
root.presets-open = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -2439,6 +2539,26 @@ in property <bool> panel-visible: true;
|
|||||||
// Below the load bar, above everything else: a download started from
|
// Below the load bar, above everything else: a download started from
|
||||||
// here shows its progress in that bar, and the bar must not be the
|
// here shows its progress in that bar, and the bar must not be the
|
||||||
// thing the dialogue covers.
|
// thing the dialogue covers.
|
||||||
|
// TRACES: FR-DEV-6
|
||||||
|
// Over the shell rather than inside a view, because both views open
|
||||||
|
// it — and because the develop column is 320px wide, which is not
|
||||||
|
// enough to list presets and rename one in.
|
||||||
|
if root.presets-open: PresetSheet {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
names: root.preset-names;
|
||||||
|
can-save: root.preset-can-save;
|
||||||
|
apply-count: root.preset-apply-count;
|
||||||
|
capture-summary: root.preset-capture-summary;
|
||||||
|
name-error: root.preset-name-error;
|
||||||
|
save(name) => { root.save-preset(name); }
|
||||||
|
apply(name) => { root.apply-preset(name); }
|
||||||
|
rename(from, to) => { root.rename-preset(from, to); }
|
||||||
|
remove(name) => { root.delete-preset(name); }
|
||||||
|
name-edited(text) => { root.preset-name-edited(text); }
|
||||||
|
dismiss => { root.presets-open = false; }
|
||||||
|
}
|
||||||
|
|
||||||
OfflinePrompt {
|
OfflinePrompt {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -468,6 +468,16 @@ export struct LibraryCell {
|
|||||||
// 0 unflagged, 1 pick, 2 reject. Independent of the stars: rejecting a
|
// 0 unflagged, 1 pick, 2 reject. Independent of the stars: rejecting a
|
||||||
// four-star frame is a normal thing to do mid-cull.
|
// four-star frame is a normal thing to do mid-cull.
|
||||||
flag: int,
|
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.
|
// 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 export-selection();
|
||||||
callback cancel-export();
|
callback cancel-export();
|
||||||
callback paste-settings-to-selection();
|
callback paste-settings-to-selection();
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
/// Open the named-preset sheet over the selection.
|
||||||
|
callback open-presets();
|
||||||
callback remove-from-collection();
|
callback remove-from-collection();
|
||||||
/// Open the sheet that files the selection in a collection.
|
/// Open the sheet that files the selection in a collection.
|
||||||
callback add-to-collection();
|
callback add-to-collection();
|
||||||
@@ -941,6 +954,20 @@ component HeaderActions inherits HorizontalLayout {
|
|||||||
clicked => { root.paste-settings-to-selection(); }
|
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
|
// TRACES: FR-EXP-7 | NFR-ARCH-3
|
||||||
// Export the selection, and stop the batch that is running.
|
// Export the selection, and stop the batch that is running.
|
||||||
//
|
//
|
||||||
@@ -1148,6 +1175,11 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
callback cell-clicked(int);
|
callback cell-clicked(int);
|
||||||
/// A star was clicked on a cell: row, and the rating 0..5.
|
/// A star was clicked on a cell: row, and the rating 0..5.
|
||||||
callback cell-rated(int, int);
|
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
|
/// Whether the grid is currently listing the trash rather than the
|
||||||
/// library. Suppresses the per-cell trash target, which would be inert
|
/// library. Suppresses the per-cell trash target, which would be inert
|
||||||
/// there — `plan_trash` skips an already-trashed image — and offering a
|
/// 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 <bool> settings-armed: false;
|
||||||
in property <string> settings-summary;
|
in property <string> settings-summary;
|
||||||
callback paste-settings-to-selection();
|
callback paste-settings-to-selection();
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
callback open-presets();
|
||||||
|
|
||||||
// TRACES: FR-EXP-7
|
// TRACES: FR-EXP-7
|
||||||
// Exporting the selection. The grid owns neither the settings that decide
|
// 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(); }
|
export-selection => { root.export-selection(); }
|
||||||
cancel-export => { root.cancel-export(); }
|
cancel-export => { root.cancel-export(); }
|
||||||
paste-settings-to-selection => { root.paste-settings-to-selection(); }
|
paste-settings-to-selection => { root.paste-settings-to-selection(); }
|
||||||
|
open-presets => { root.open-presets(); }
|
||||||
remove-from-collection => { root.remove-from-collection(); }
|
remove-from-collection => { root.remove-from-collection(); }
|
||||||
select-mode: root.select-mode;
|
select-mode: root.select-mode;
|
||||||
toggle-select-mode => { root.toggle-select-mode(); }
|
toggle-select-mode => { root.toggle-select-mode(); }
|
||||||
@@ -1904,6 +1939,7 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
export-selection => { root.export-selection(); }
|
export-selection => { root.export-selection(); }
|
||||||
cancel-export => { root.cancel-export(); }
|
cancel-export => { root.cancel-export(); }
|
||||||
paste-settings-to-selection => { root.paste-settings-to-selection(); }
|
paste-settings-to-selection => { root.paste-settings-to-selection(); }
|
||||||
|
open-presets => { root.open-presets(); }
|
||||||
remove-from-collection => { root.remove-from-collection(); }
|
remove-from-collection => { root.remove-from-collection(); }
|
||||||
select-mode: root.select-mode;
|
select-mode: root.select-mode;
|
||||||
toggle-select-mode => { root.toggle-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); }
|
rate(n) => { root.cell-rated(i, n); }
|
||||||
trash() => { root.cell-trashed(i); }
|
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
|
// Where the run would land. A bar in the gutter beside the
|
||||||
|
|||||||
@@ -137,10 +137,18 @@ component MaskEntry inherits Rectangle {
|
|||||||
alignment: center;
|
alignment: center;
|
||||||
spacing: 0px;
|
spacing: 0px;
|
||||||
|
|
||||||
|
// Both lines below are bounded for the reason the subject
|
||||||
|
// row is: a mask's label and kind come from the model, and an
|
||||||
|
// unbounded `Text` asks for its whole string at layout time
|
||||||
|
// even when `elide` means it will never draw it. A mask *is* a
|
||||||
|
// segmentation result, so without this the column moved when a
|
||||||
|
// subject was clicked as well as when one was found.
|
||||||
Label {
|
Label {
|
||||||
text: root.data.label;
|
text: root.data.label;
|
||||||
emphasised: root.data.selected || touch.has-hover;
|
emphasised: root.data.selected || touch.has-hover;
|
||||||
overflow: elide;
|
overflow: elide;
|
||||||
|
min-width: 0px;
|
||||||
|
max-width: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
Caption {
|
Caption {
|
||||||
@@ -148,6 +156,8 @@ component MaskEntry inherits Rectangle {
|
|||||||
// targets in a row, and wrapping would give the rows of a
|
// targets in a row, and wrapping would give the rows of a
|
||||||
// stack different heights for no gain.
|
// stack different heights for no gain.
|
||||||
overflow: elide;
|
overflow: elide;
|
||||||
|
min-width: 0px;
|
||||||
|
max-width: 160px;
|
||||||
// Three states worth distinguishing, and each has a
|
// Three states worth distinguishing, and each has a
|
||||||
// different remedy: stale needs the segmentation re-run,
|
// different remedy: stale needs the segmentation re-run,
|
||||||
// unadjusted needs a slider moved, and the ordinary case
|
// unadjusted needs a slider moved, and the ordinary case
|
||||||
@@ -418,6 +428,30 @@ export component MaskPanel inherits Rectangle {
|
|||||||
emphasised: subject-row.has-hover;
|
emphasised: subject-row.has-hover;
|
||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
overflow: elide;
|
overflow: elide;
|
||||||
|
// **`elide` is a paint-time behaviour, and this is a
|
||||||
|
// layout-time problem.** A `Text` asks for the width of
|
||||||
|
// its whole string whether or not it will draw all of
|
||||||
|
// it, so without a stated maximum this row asked for
|
||||||
|
// whatever the model happened to return, that became
|
||||||
|
// `layout.preferred-width`, the panel publishes that as
|
||||||
|
// its `min-width`, and the develop column takes the
|
||||||
|
// widest minimum any panel declares. The column
|
||||||
|
// therefore moved the instant segmentation finished —
|
||||||
|
// a photograph the user was looking at, jumping
|
||||||
|
// sideways because a label said "traffic light".
|
||||||
|
//
|
||||||
|
// Stated as a maximum for the reason `ChipGrid`
|
||||||
|
// declares its width from its column count rather than
|
||||||
|
// from its options: what a panel asks for must follow
|
||||||
|
// from its structure, never from its data. Past this
|
||||||
|
// the row elides, which is what `elide` was for.
|
||||||
|
//
|
||||||
|
// 160px is the same judgement as `ChipGrid`'s 88px
|
||||||
|
// chip — comfortable for the class names this model
|
||||||
|
// returns, and narrow enough that a subject list
|
||||||
|
// cannot be what sets the column.
|
||||||
|
min-width: 0px;
|
||||||
|
max-width: 160px;
|
||||||
}
|
}
|
||||||
Value { text: round(subject.score * 100) + "%"; }
|
Value { text: round(subject.score * 100) + "%"; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// TRACES: FR-CULL-3
|
||||||
|
// The focus-peaking switch, and the two choices it exposes.
|
||||||
|
//
|
||||||
|
// **An instrument, not an operation**, exactly as the histogram above it is:
|
||||||
|
// it has no parameters in the edit graph, changes nothing about the
|
||||||
|
// photograph, and answers a question rather than asking one. So it is written
|
||||||
|
// by hand rather than generated from a descriptor, and FR-DEV-3a is untroubled
|
||||||
|
// by it — nothing here names an operation or reads a parameter out of one.
|
||||||
|
//
|
||||||
|
// **Both choices are words, not swatches.** The colour picker is the obvious
|
||||||
|
// place to draw four coloured squares, and NFR-A11Y-3 is the reason not to:
|
||||||
|
// a control for choosing between hues, presented only as hues, is unusable by
|
||||||
|
// the person most likely to need to change it. The chips say "Red" and "Cyan".
|
||||||
|
//
|
||||||
|
// **Why the two chip rows only exist while peaking is on.** They are settings
|
||||||
|
// for something that is not happening, and the develop column is the
|
||||||
|
// photographer's instrument panel — every row it holds is a slider pushed
|
||||||
|
// below the fold. The panel's own height is bound to its content, so the
|
||||||
|
// column reflows rather than leaving a gap.
|
||||||
|
|
||||||
|
import { Theme } from "theme.slint";
|
||||||
|
import { Button, PanelHeading, Caption } from "widgets.slint";
|
||||||
|
import { Segmented } from "controls.slint";
|
||||||
|
|
||||||
|
// TRACES: FR-CULL-3
|
||||||
|
// The marks themselves, composited over the canvas.
|
||||||
|
//
|
||||||
|
// **A layer over the photograph and not a tint in it**, which is the same rule
|
||||||
|
// `app.slint` states on the region map: a diagnostic "must not reach the
|
||||||
|
// histogram, an export, or the texture the develop pass hands the compositor".
|
||||||
|
// `HistogramPass` counts whatever the develop pass last rendered, so marks
|
||||||
|
// painted into that frame would arrive in the histogram as a spike and in the
|
||||||
|
// clipping figure as blown highlights. The layer is transparent everywhere
|
||||||
|
// except where something is in focus.
|
||||||
|
//
|
||||||
|
// **No `source-clip` and no rotation**, unlike the region map. That is a
|
||||||
|
// source-space picture being windowed down to the visible part; this was
|
||||||
|
// measured on the rendered frame itself, so it is already cropped, zoomed and
|
||||||
|
// turned exactly as the canvas is. One fewer thing that can drift out of
|
||||||
|
// registration.
|
||||||
|
//
|
||||||
|
// The caller places it on `canvas-area`'s fitted rect, which is the shared
|
||||||
|
// contract for anything that lands on the picture.
|
||||||
|
export component FocusMarks inherits Image {
|
||||||
|
/// The overlay `DevelopSession::focus_overlay` produced for this frame.
|
||||||
|
in property <image> marks;
|
||||||
|
|
||||||
|
source: root.marks;
|
||||||
|
image-fit: fill;
|
||||||
|
// Nearest-neighbour: a mark is one pixel wide, and smoothing spreads it
|
||||||
|
// into a grey haze that reads as softness — the opposite of what it is
|
||||||
|
// reporting.
|
||||||
|
image-rendering: ImageRendering.pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-CULL-3
|
||||||
|
export component FocusPanel inherits Rectangle {
|
||||||
|
/// Whether this device could build the overlay at all.
|
||||||
|
///
|
||||||
|
/// A compute pass can fail to compile on a driver nobody here has, and the
|
||||||
|
/// honest response is to say so rather than to offer a switch that does
|
||||||
|
/// nothing when pressed. The develop view keeps working without it; only
|
||||||
|
/// this panel changes.
|
||||||
|
in property <bool> available: true;
|
||||||
|
/// Whether the overlay is currently being drawn.
|
||||||
|
///
|
||||||
|
/// `showing` rather than the obvious `on`: Slint has no reserved word
|
||||||
|
/// there today, and a one-word property that might become one is not worth
|
||||||
|
/// the bet on a panel this small.
|
||||||
|
in property <bool> showing: false;
|
||||||
|
/// Index into `PeakSensitivity`, in the order Rust declares it.
|
||||||
|
in property <int> sensitivity: 1;
|
||||||
|
/// Index into `PeakColour`, likewise.
|
||||||
|
in property <int> colour: 0;
|
||||||
|
|
||||||
|
callback toggled(bool);
|
||||||
|
callback sensitivity-picked(int);
|
||||||
|
callback colour-picked(int);
|
||||||
|
|
||||||
|
background: Theme.surface;
|
||||||
|
|
||||||
|
/// TRACES: FR-UI-2
|
||||||
|
/// How wide this panel has to be before it clips itself. The develop
|
||||||
|
/// column is the largest of these and nothing else; see `SpotPanel` and
|
||||||
|
/// `HistogramPanel` for the whole protocol.
|
||||||
|
///
|
||||||
|
/// Both chip rows wrap at three, which is what keeps this number at the
|
||||||
|
/// narrowest column the application supports rather than at four chips
|
||||||
|
/// abreast — a single row of four would set the width of the entire
|
||||||
|
/// sidebar for every other panel in it.
|
||||||
|
out property <length> content-width: layout.preferred-width;
|
||||||
|
min-width: root.content-width;
|
||||||
|
|
||||||
|
// Flat rather than nested, for the reason `SpotPanel` and `MaskPanel` both
|
||||||
|
// give: a nested conditional layout under-reports its height here and the
|
||||||
|
// rows below it get drawn on top of one another. Every row carries its own
|
||||||
|
// `if`.
|
||||||
|
layout := VerticalLayout {
|
||||||
|
padding: Theme.gap;
|
||||||
|
spacing: Theme.gap-sm;
|
||||||
|
alignment: start;
|
||||||
|
|
||||||
|
PanelHeading { text: "FOCUS"; }
|
||||||
|
|
||||||
|
if !root.available: Caption {
|
||||||
|
text: "This device could not build the overlay.";
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.available: Button {
|
||||||
|
// The label states the action rather than the state, as the mask
|
||||||
|
// overlay's does: a photographer reads a button for what pressing
|
||||||
|
// it will do.
|
||||||
|
text: root.showing ? "Hide focus peaking" : "Show focus peaking";
|
||||||
|
active: root.showing;
|
||||||
|
clicked => { root.toggled(!root.showing); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.available && root.showing: Segmented {
|
||||||
|
label: "Sensitivity";
|
||||||
|
hint: "lower on a noisy frame";
|
||||||
|
options: ["Low", "Medium", "High"];
|
||||||
|
selected: root.sensitivity;
|
||||||
|
columns: 3;
|
||||||
|
picked(i) => { root.sensitivity-picked(i); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.available && root.showing: Segmented {
|
||||||
|
label: "Marks";
|
||||||
|
hint: "pick what the subject is not";
|
||||||
|
options: ["Red", "Yellow", "Cyan", "Magenta"];
|
||||||
|
selected: root.colour;
|
||||||
|
columns: 3;
|
||||||
|
picked(i) => { root.colour-picked(i); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.available && root.showing: Caption {
|
||||||
|
// Said once, here, rather than left to be discovered: the marks go
|
||||||
|
// away while a control is moving because a half-resolution draft
|
||||||
|
// frame cannot be measured for sharpness (see `FocusPeakPass`).
|
||||||
|
//
|
||||||
|
// Kept to one short sentence on purpose. A wrapping Text reports
|
||||||
|
// its *unwrapped* width as its preferred one, and this panel's
|
||||||
|
// `content-width` is what the develop column sizes itself from —
|
||||||
|
// a paragraph here would hold the whole sidebar open.
|
||||||
|
text: "Marks pause while a control is dragged.";
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { Theme } from "theme.slint";
|
||||||
|
import { Button, Field, Caption } from "widgets.slint";
|
||||||
|
|
||||||
|
// TRACES: FR-DEV-6
|
||||||
|
// The named preset sheet: save the edit in hand, and apply a saved one.
|
||||||
|
//
|
||||||
|
// # One sheet for both views
|
||||||
|
//
|
||||||
|
// A preset is saved in develop, where there is an edit to capture, and applied
|
||||||
|
// most often in the library, where there is a selection to apply it to. Those
|
||||||
|
// are two different moments and it is the same list, so this is one component
|
||||||
|
// mounted at the shell rather than a panel in each view — the same reasoning
|
||||||
|
// the clipboard's properties are declared on the window for.
|
||||||
|
//
|
||||||
|
// What differs between the two is not the sheet but its *answers*: `can-save`
|
||||||
|
// is false with nothing open, and `apply-count` says whether applying means
|
||||||
|
// this photograph or those forty. Both are facts the shell already holds.
|
||||||
|
//
|
||||||
|
// # The same card, scrim and dismissal as the filing and keywording sheets
|
||||||
|
//
|
||||||
|
// Deliberately. A user who has filed a selection knows how this works, and a
|
||||||
|
// second idiom for the same gesture would be a second thing to learn for no
|
||||||
|
// gain.
|
||||||
|
export component PresetSheet inherits Rectangle {
|
||||||
|
/// The saved names, in the order they are stored.
|
||||||
|
in property <[string]> names;
|
||||||
|
/// Whether there is an edit in hand to save. False in the library, where
|
||||||
|
/// nothing is open, and with an image that failed to decode.
|
||||||
|
in property <bool> can-save: false;
|
||||||
|
/// What a preset would be applied to: 0 means the open photograph, and
|
||||||
|
/// anything higher means that many selected ones.
|
||||||
|
in property <int> apply-count: 0;
|
||||||
|
/// What saving would capture — "3 adjustments" — from the same routine the
|
||||||
|
/// clipboard's summary comes from, so the two cannot disagree.
|
||||||
|
in property <string> capture-summary;
|
||||||
|
/// Set while a name is refused, and cleared by the next keystroke. Prose
|
||||||
|
/// rather than a code, because the shell knows why and this does not.
|
||||||
|
in property <string> name-error;
|
||||||
|
|
||||||
|
callback save(string);
|
||||||
|
callback apply(string);
|
||||||
|
callback rename(string, string);
|
||||||
|
callback remove(string);
|
||||||
|
callback dismiss();
|
||||||
|
/// Every keystroke in the name field, so the shell can clear a refusal the
|
||||||
|
/// user has started correcting.
|
||||||
|
callback name-edited(string);
|
||||||
|
|
||||||
|
background: #000000CC;
|
||||||
|
|
||||||
|
// Swallows the taps that miss the card, and closes. First, so the card's
|
||||||
|
// own controls sit above it.
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.dismiss(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which row is being renamed, by name. Empty means none.
|
||||||
|
//
|
||||||
|
// A name rather than an index: the list is rebuilt from Rust after every
|
||||||
|
// change, and an index would point at whatever moved into that slot.
|
||||||
|
property <string> renaming: "";
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: min(420px, parent.width - 2 * Theme.gap-lg);
|
||||||
|
height: min(sheet.preferred-height, parent.height - 2 * Theme.gap-lg);
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
background: Theme.surface;
|
||||||
|
border-radius: Theme.radius;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.rule;
|
||||||
|
|
||||||
|
// Stops a press on the card reaching the scrim behind it.
|
||||||
|
TouchArea { }
|
||||||
|
|
||||||
|
sheet := VerticalLayout {
|
||||||
|
padding: Theme.gap-lg;
|
||||||
|
spacing: Theme.gap;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Presets";
|
||||||
|
color: Theme.ink;
|
||||||
|
font-size: Theme.text-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saving, first: it is the half that has something to say about
|
||||||
|
// the photograph currently open, and it disappears entirely in the
|
||||||
|
// library rather than sitting there disabled — a permanently dead
|
||||||
|
// control teaches the reader that the sheet lies.
|
||||||
|
if root.can-save: VerticalLayout {
|
||||||
|
spacing: Theme.gap-sm;
|
||||||
|
|
||||||
|
name := Field {
|
||||||
|
placeholder: "Name this edit and press return";
|
||||||
|
accepted(text) => {
|
||||||
|
root.save(text);
|
||||||
|
// Cleared only once the shell has accepted it. A
|
||||||
|
// refused name the user has to retype is a refusal
|
||||||
|
// that costs more than the mistake did, so the field
|
||||||
|
// keeps the text and `name-error` says why.
|
||||||
|
if (root.name-error == "") {
|
||||||
|
self.text = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
edited(text) => { root.name-edited(text); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.name-error != "": Caption {
|
||||||
|
text: root.name-error;
|
||||||
|
warn: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bare summary, phrased exactly as the develop panel
|
||||||
|
// phrases the clipboard's — same routine, same words, so the
|
||||||
|
// two cannot appear to disagree about one edit.
|
||||||
|
if root.name-error == "": Caption {
|
||||||
|
text: root.capture-summary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.can-save: Rectangle { height: 1px; background: Theme.rule; }
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
vertical-stretch: 1;
|
||||||
|
// A floor, so the list is not squeezed out of existence by the
|
||||||
|
// field and the button around it on a short window.
|
||||||
|
min-height: 120px;
|
||||||
|
viewport-height: root.names.length * (Theme.touch-target + 2px);
|
||||||
|
|
||||||
|
for entry[i] in root.names: Rectangle {
|
||||||
|
y: i * (Theme.touch-target + 2px);
|
||||||
|
width: parent.width;
|
||||||
|
height: Theme.touch-target;
|
||||||
|
|
||||||
|
if root.renaming != entry: HorizontalLayout {
|
||||||
|
spacing: Theme.gap-sm;
|
||||||
|
|
||||||
|
// The name is the apply button rather than a label
|
||||||
|
// beside one. Applying is what this list is for, and a
|
||||||
|
// row whose largest target does nothing is a row that
|
||||||
|
// gets pressed by accident and then distrusted.
|
||||||
|
Button {
|
||||||
|
text: entry;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
clicked => { root.apply(entry); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Rename";
|
||||||
|
clicked => { root.renaming = entry; }
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Delete";
|
||||||
|
clicked => { root.remove(entry); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renaming in place rather than in a second sheet: a
|
||||||
|
// dialogue over a dialogue is where a user loses track of
|
||||||
|
// which one Escape closes.
|
||||||
|
if root.renaming == entry: HorizontalLayout {
|
||||||
|
spacing: Theme.gap-sm;
|
||||||
|
|
||||||
|
rename-field := Field {
|
||||||
|
text: entry;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
accepted(text) => {
|
||||||
|
root.rename(entry, text);
|
||||||
|
root.renaming = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Cancel";
|
||||||
|
clicked => { root.renaming = ""; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.names.length == 0: Text {
|
||||||
|
text: root.can-save
|
||||||
|
? "No presets yet. Name the edit above to make the first."
|
||||||
|
: "No presets yet. Open a photograph and save one from the develop panel.";
|
||||||
|
color: Theme.ink-faint;
|
||||||
|
font-size: Theme.text-sm;
|
||||||
|
wrap: word-wrap;
|
||||||
|
width: parent.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Says what applying would do *before* it is done, the same way
|
||||||
|
// the grid's "Paste to 40" does — a count in the label is worth
|
||||||
|
// more than a confirmation asking the same question afterwards.
|
||||||
|
if root.names.length > 0 && root.apply-count > 0: Caption {
|
||||||
|
text: root.apply-count == 1
|
||||||
|
? "Applies to 1 selected photograph"
|
||||||
|
: "Applies to " + root.apply-count + " selected photographs";
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 1px; background: Theme.rule; }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Done";
|
||||||
|
clicked => { root.dismiss(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// TRACES: FR-UI-6
|
||||||
// Shared chrome primitives and the style layer.
|
// Shared chrome primitives and the style layer.
|
||||||
//
|
//
|
||||||
// Before this file every button was a Rectangle + TouchArea written out where
|
// Before this file every button was a Rectangle + TouchArea written out where
|
||||||
|
|||||||
Reference in New Issue
Block a user