Let a photograph leave: an export button, and a cache to leave from

dr-export could turn a frame into bytes and nothing could ask it to. This is
the button, and the place the bytes go.

**Everything is staged first.** An export bound for the server is written to a
local outbox and uploaded afterwards; offline is not a special case, it is the
same path with a drain that finds the server absent. Doing it the other way —
upload directly, stage only on failure — makes the failure path the one that
is rarely exercised and always broken, and a network drop mid-batch leaves
some exports existing and some not with nothing recording which. Staged first,
an export is finished the moment it is written and the upload is a promise
kept later.

The outbox sits beside the catalog rather than under the cache. dr_catalog's
cache already draws that line: passive entries are a convenience and go under
LRU, pinned ones are a promise and never do. An export awaiting upload is a
promise — the user was told it succeeded — and sweeping it for disk would
destroy the only copy. Bytes are written before the destination record, so a
kill between the two leaves an orphan the drain ignores rather than a record
pointing at nothing.

The status line says "Queued for Exports/2026", never "Exported to Nextcloud",
until it has actually landed. There is a test asserting that wording, because
the tempting shorter sentence is a claim the app cannot keep.

The drain runs on the sync pass, before the shards: a thumbnail shard can be
rebuilt from the originals and the catalog is an index, but a queued export
exists nowhere else.

`DevelopSession::render_for_export` renders the framed size rather than reusing
the frame on screen, which is deliberately viewport-sized (FR-DSP-1) — encoding
that would hand the user a soft, screen-sized file with nothing to say anything
had been lost (FR-EXP-9).

One compromise, recorded rather than hidden: the export runs synchronously on
the UI thread, so the window is unresponsive for the few hundred milliseconds
a full-resolution render and encode takes. Moving a DevelopSession and its GPU
pass to a worker is a larger change than one button earns, and it is batch
export that makes the wait intolerable rather than merely noticeable.

Still missing: the Nextcloud folder *picker*. The destination is typed into
Settings for now. `FolderBrowser` in launch.rs is already the reusable model
for it — it browses a remote tree and nothing about it is specific to choosing
a library root — but wiring it into the settings page needs a listing worker
and browser UI there, which is its own piece of work.

Carries in-flight work from a parallel session — presets, the develop copy and
paste, and the node schema's `presentation` and `enum` support. One misplaced
callback in settings_ui.rs is moved from `render` to `wire`: registered in
`render` it borrowed a `&SettingsController` into a 'static closure and would
not compile, and that file's own docs say render pushes properties while wire
connects callbacks.

992 tests pass, clippy and fmt clean. Traceability 48.3% -> 51.0%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 23:58:17 +02:00
co-authored by Claude Opus 5
parent 23f0c4b76a
commit e00c99b864
19 changed files with 2845 additions and 85 deletions
+1
View File
@@ -18,6 +18,7 @@ reqwest.workspace = true
dr-plat.workspace = true
dr-sync.workspace = true
dr-sync-nextcloud.workspace = true
dr-export.workspace = true
dr-pipeline.workspace = true
dr-catalog.workspace = true
dr-thumbs.workspace = true
+61 -1
View File
@@ -13,7 +13,8 @@ use dr_decode::RawImage;
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext};
use dr_pipeline::ops::curve;
use dr_pipeline::{
CropRect, EditGraph, OpCapability, OpId, ParamId, ParamKind, Presentation, Unit, WidgetKind,
CropRect, EditGraph, OpCapability, OpId, ParamId, ParamKind, Presentation, Preset, Scope, Unit,
WidgetKind,
};
use crate::labels;
@@ -538,6 +539,35 @@ impl DevelopSession {
self.graph.output_size(w, h)
}
/// TRACES: FR-EXP-9
/// Render at full resolution and hand back the pixels, for an export.
///
/// **Not the frame on screen.** [`Self::render`] deliberately renders at
/// viewport size, which is what keeps a slider inside the frame budget on
/// a 24 MP file (FR-DSP-1) — and what would make an export of it a soft,
/// screen-sized file. This renders the framed output size instead, so the
/// export is the full-quality path FR-EXP-9 requires.
///
/// The readback here is `export_pixels`, not the display bridge: a file
/// is made of bytes on the CPU and there is no path to one that avoids
/// the transfer. See the note on that method for why the two are separate.
///
/// Leaves the pass holding a full-resolution target, so the caller should
/// expect the next display render to reallocate. Cheaper than keeping a
/// second pass alive for the exports a session rarely performs.
pub fn render_for_export(&mut self) -> Result<dr_export::Frame, String> {
let (sw, sh) = self.demosaiced.size();
let (w, h) = self.graph.output_size(sw, sh);
let shader = self.graph.compose();
self.adjust
.render(&self.demosaiced, &shader, w, h)
.map_err(|e| e.to_string())?;
let (pixels, rw, rh) = self.adjust.export_pixels().map_err(|e| e.to_string())?;
dr_export::Frame::new(rw, rh, pixels).map_err(|e| e.to_string())
}
/// The sensor's own dimensions, before framing.
///
/// What a crop overlay needs: its handles are placed against the full
@@ -767,6 +797,36 @@ impl DevelopSession {
pub fn is_neutral(&self) -> bool {
self.graph.is_neutral()
}
/// TRACES: FR-DEV-6
/// Lift this session's edit onto the clipboard.
///
/// Captured at full scope — framing included — because the decision about
/// what travels is made when the preset is *applied*. Copying, then
/// changing one's mind about the crop, must not mean copying again.
pub fn copy_settings(&self) -> Preset {
Preset::capture(&self.graph)
}
/// TRACES: FR-DEV-6
/// Replace this session's edit within `scope`.
///
/// The panel must be rebuilt from [`Self::rows`] afterwards: a paste moves
/// values the sliders are showing, and nothing here pushes them.
pub fn apply_settings(&mut self, preset: &Preset, scope: Scope) {
preset.apply(&mut self.graph, scope);
}
/// TRACES: FR-CAT-8
/// Load a stored edit, as read from this image's sidecar.
///
/// A replacement rather than an overlay — [`Version::apply`] resets first —
/// so a version that stores nothing opens the photograph at its defaults
/// rather than leaving the previous image's exposure standing. The file's
/// orientation survives it, since that was never an edit.
pub fn apply_version(&mut self, version: &dr_pipeline::Version) {
version.apply(&mut self.graph);
}
}
/// Re-express a crop rect after the frame it is measured against turns.
+599
View File
@@ -0,0 +1,599 @@
//! TRACES: FR-EXP-6 | FR-EXP-7 | FR-NC-10
//! Placing an exported file, and the cache that makes offline unremarkable.
//!
//! [`dr_export`] turns a frame into bytes and a name and stops there, because
//! where those bytes go differs by more than a path. This is the other half:
//! it decides the destination and gets them there.
//!
//! # Everything is staged first
//!
//! An export to the server is written to a local **outbox** before any upload
//! is attempted, and the upload drains that outbox afterwards. Not a fallback
//! for the offline case — the *only* path, with offline merely meaning the
//! drain finds nothing to do.
//!
//! Doing it the other way, uploading directly and staging only on failure,
//! looks simpler and has two bad properties. The failure path is then the one
//! that is rarely exercised and always broken, and the moment the network
//! drops mid-batch some exports exist and some do not with nothing recording
//! which. Staging first means an export is *finished* the instant it is
//! written; the upload is a separate promise the app keeps later.
//!
//! # Why the outbox is not in the cache
//!
//! It lives beside the catalog, with the thumbnail shards, rather than under
//! the evictable cache. `dr_catalog::cache` draws the line already: passively
//! cached originals are a convenience and go under LRU, pinned ones are a
//! promise and never do. An export waiting to upload is a promise — the user
//! was told the export succeeded — and sweeping it away to reclaim disk would
//! destroy work that no longer exists anywhere else.
use std::path::{Path, PathBuf};
use dr_export::Encoded;
use dr_sync::{RemoteBackend, RemotePath};
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
use dr_types::ExportTarget;
/// Where exports wait for a server that is not there yet.
///
/// Beside the catalog, for the reason in the module docs. Per account,
/// because the destination folder is a path on one particular server and an
/// entry queued for one account is meaningless to another.
pub fn outbox_dir(server: &str, user_id: &str) -> PathBuf {
crate::library::catalog_path(server, user_id)
.parent()
.map(|p| p.join("outbox"))
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-outbox"))
}
/// One export waiting to go up.
///
/// The record sits beside the bytes as `<name>.dest`, holding the remote
/// folder it belongs in. A single flat file rather than a database: the queue
/// is small, the entries are independent, and the recovery story for a
/// half-written text file is to ignore it — which is exactly what parsing it
/// does.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pending {
/// The staged bytes on this device.
pub local: PathBuf,
/// Remote folder, relative to the library root. Empty means the root.
pub remote_dir: String,
/// The filename to give it there.
pub name: String,
}
impl Pending {
/// Full remote path for this entry, under `root`.
fn remote_path(&self, root: &str) -> RemotePath {
let mut parts: Vec<&str> = Vec::new();
for segment in [root, self.remote_dir.as_str()] {
for part in segment.split('/') {
if !part.is_empty() {
parts.push(part);
}
}
}
parts.push(&self.name);
RemotePath::new(parts.join("/"))
}
/// The folder this entry's file belongs in, as a remote path.
fn remote_folder(&self, root: &str) -> RemotePath {
let mut parts: Vec<&str> = Vec::new();
for segment in [root, self.remote_dir.as_str()] {
for part in segment.split('/') {
if !part.is_empty() {
parts.push(part);
}
}
}
RemotePath::new(parts.join("/"))
}
}
/// Where an export was put, for the interface to report.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Placed {
/// Written straight to a folder on this device.
Device(PathBuf),
/// Staged locally, awaiting upload to the named remote folder.
Queued { local: PathBuf, remote_dir: String },
}
impl Placed {
/// A sentence for the status line.
pub fn describe(&self) -> String {
match self {
Placed::Device(path) => format!(
"Exported {}",
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
),
// Named as queued rather than exported: the file is real and
// finished, but it is not yet where the user asked for it, and
// saying "exported to Nextcloud" before it has uploaded would be
// a claim the app cannot keep if the disk is pulled.
Placed::Queued { remote_dir, .. } => {
let dir = if remote_dir.is_empty() {
"the library root".to_string()
} else {
remote_dir.clone()
};
format!("Queued for {dir}")
}
}
}
}
/// Write an encoded export to wherever the settings say it goes.
///
/// `destination` is a filesystem path for [`ExportTarget::Device`] and a
/// remote folder for [`ExportTarget::Remote`] — the widening `ExportSettings`
/// documents, resolved here because this is the layer that knows what a path
/// means on this platform.
pub fn place(
encoded: &Encoded,
target: ExportTarget,
destination: &str,
outbox: &Path,
) -> Result<Placed, String> {
match target {
ExportTarget::Device => {
if destination.trim().is_empty() {
return Err("No export folder is set. Choose one in Settings.".into());
}
let dir = PathBuf::from(destination);
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let path = dir.join(&encoded.name);
std::fs::write(&path, &encoded.bytes)
.map_err(|e| format!("{}: {e}", path.display()))?;
Ok(Placed::Device(path))
}
ExportTarget::Remote => {
let local = stage(encoded, destination, outbox)?;
Ok(Placed::Queued {
local,
remote_dir: destination.to_string(),
})
}
}
}
/// Write bytes and their destination record into the outbox.
fn stage(encoded: &Encoded, remote_dir: &str, outbox: &Path) -> Result<PathBuf, String> {
std::fs::create_dir_all(outbox).map_err(|e| format!("{}: {e}", outbox.display()))?;
// The staged name is the export's name, deduplicated against the outbox
// rather than against the server: two exports queued before either has
// uploaded would otherwise overwrite each other here, and the second one
// would silently replace the first before anybody saw it.
let mut candidate = outbox.join(&encoded.name);
let mut n = 1;
while candidate.exists() {
let stem = Path::new(&encoded.name)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "export".into());
let ext = Path::new(&encoded.name)
.extension()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
candidate = outbox.join(format!("{stem}-{n}.{ext}"));
n += 1;
if n > 10_000 {
return Err("the outbox is full of files by this name".into());
}
}
// Bytes first, then the record. The order matters on a process that may
// be killed between the two: an orphan payload with no record is ignored
// by the drain and swept later, where a record naming bytes that were
// never written would be a permanent failure retried forever.
std::fs::write(&candidate, &encoded.bytes)
.map_err(|e| format!("{}: {e}", candidate.display()))?;
let record = candidate.with_extension(format!(
"{}.dest",
candidate
.extension()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
));
// The remote folder and the intended name, one per line. Not JSON: two
// strings do not need a parser, and a format a human can repair by hand
// is worth something for a queue holding the only copy of someone's work.
std::fs::write(&record, format!("{remote_dir}\n{}\n", encoded.name))
.map_err(|e| format!("{}: {e}", record.display()))?;
Ok(candidate)
}
/// Everything currently waiting in the outbox.
///
/// A payload with no record is skipped rather than guessed at — see the write
/// order in [`stage`].
pub fn pending(outbox: &Path) -> Vec<Pending> {
let Ok(entries) = std::fs::read_dir(outbox) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("dest") {
continue;
}
// `photo.jpg.dest` describes `photo.jpg`.
let local = path.with_extension("");
if !local.exists() {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let mut lines = text.lines();
let remote_dir = lines.next().unwrap_or("").to_string();
let name = lines.next().unwrap_or("").to_string();
if name.is_empty() {
continue;
}
out.push(Pending {
local,
remote_dir,
name,
});
}
// Stable order so a drain is reproducible and a stuck entry is obvious
// rather than appearing to move around the queue.
out.sort_by(|a, b| a.local.cmp(&b.local));
out
}
/// How many exports are waiting. For the interface to show, and cheap enough
/// to call on a redraw.
pub fn pending_count(outbox: &Path) -> usize {
pending(outbox).len()
}
/// Remove an entry and its record, once it is safely on the server.
fn clear(entry: &Pending) {
let record = PathBuf::from(format!("{}.dest", entry.local.display()));
let _ = std::fs::remove_file(&entry.local);
let _ = std::fs::remove_file(&record);
}
/// Progress from the upload worker.
#[derive(Debug)]
pub enum UploadMessage {
Status(String),
/// Uploaded, still pending, and the first error if there was one.
Finished {
uploaded: usize,
remaining: usize,
error: Option<String>,
},
}
/// Drain the outbox to the server.
///
/// Its own thread with its own runtime, like every other network path here —
/// the Slint loop must never block (NFR-P9).
///
/// A failure leaves the entry in place and stops the run. Continuing past a
/// network error would burn the whole queue against a server that is not
/// answering, and the next pass costs nothing.
pub fn spawn_upload(
creds: AppCredentials,
user_id: String,
root: String,
outbox: PathBuf,
) -> std::sync::mpsc::Receiver<UploadMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let rt = match crate::net_runtime::build() {
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(UploadMessage::Finished {
uploaded: 0,
remaining: pending_count(&outbox),
error: Some(e.to_string()),
});
return;
}
};
rt.block_on(async {
let backend = match NextcloudBackend::new(&creds, &user_id) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(UploadMessage::Finished {
uploaded: 0,
remaining: pending_count(&outbox),
error: Some(e.to_string()),
});
return;
}
};
let queue = pending(&outbox);
let total = queue.len();
let mut uploaded = 0;
let mut error = None;
for (i, entry) in queue.iter().enumerate() {
let _ = tx.send(UploadMessage::Status(format!(
"uploading {} ({}/{total})",
entry.name,
i + 1
)));
let Ok(bytes) = std::fs::read(&entry.local) else {
// The payload vanished under us. Drop the record too;
// retrying forever against a file that is gone helps
// nobody.
clear(entry);
continue;
};
// The folder may not exist — this is the first export into it
// — and `create_dir` treats "already there" as success, so it
// is unconditional rather than guarded by a check that would
// cost a request every time.
if let Err(e) = backend.create_dir(&entry.remote_folder(&root)).await {
error = Some(e.to_string());
break;
}
match backend.put(&entry.remote_path(&root), bytes, None).await {
Ok(_) => {
clear(entry);
uploaded += 1;
}
Err(e) => {
error = Some(e.to_string());
break;
}
}
}
let _ = tx.send(UploadMessage::Finished {
uploaded,
remaining: pending_count(&outbox),
error,
});
});
});
rx
}
#[cfg(test)]
mod tests {
use super::*;
fn encoded(name: &str, bytes: &[u8]) -> Encoded {
Encoded {
name: name.to_string(),
bytes: bytes.to_vec(),
width: 4,
height: 4,
}
}
fn tmp() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"dr-outbox-test-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_device_export_writes_the_file() {
let dir = tmp();
let target = dir.join("exports");
let placed = place(
&encoded("a.jpg", b"hello"),
ExportTarget::Device,
target.to_str().unwrap(),
&dir.join("outbox"),
)
.unwrap();
assert_eq!(placed, Placed::Device(target.join("a.jpg")));
assert_eq!(std::fs::read(target.join("a.jpg")).unwrap(), b"hello");
}
#[test]
fn a_device_export_creates_a_folder_that_is_not_there() {
// Exporting into a folder the user typed but has not made is the
// common case, not an error.
let dir = tmp();
let target = dir.join("deep/nested/exports");
assert!(place(
&encoded("a.jpg", b"x"),
ExportTarget::Device,
target.to_str().unwrap(),
&dir,
)
.is_ok());
assert!(target.join("a.jpg").exists());
}
#[test]
fn a_device_export_with_no_folder_says_so() {
// Rather than writing to the process's working directory, which is
// wherever the app happened to be launched from.
let dir = tmp();
let err = place(&encoded("a.jpg", b"x"), ExportTarget::Device, " ", &dir).unwrap_err();
assert!(err.contains("Settings"), "unhelpful message: {err}");
}
#[test]
fn a_remote_export_is_staged_rather_than_sent() {
// The property the offline story rests on: the export is complete on
// disk before any network call is attempted.
let dir = tmp();
let outbox = dir.join("outbox");
let placed = place(
&encoded("a.jpg", b"hello"),
ExportTarget::Remote,
"Exports/2026",
&outbox,
)
.unwrap();
match placed {
Placed::Queued { local, remote_dir } => {
assert_eq!(std::fs::read(&local).unwrap(), b"hello");
assert_eq!(remote_dir, "Exports/2026");
}
other => panic!("expected a queued export, got {other:?}"),
}
}
#[test]
fn a_staged_export_is_found_again_with_its_destination() {
// What survives a process death: the drain has to be able to
// reconstruct where a file was going from the disk alone.
let dir = tmp();
let outbox = dir.join("outbox");
place(
&encoded("a.jpg", b"one"),
ExportTarget::Remote,
"Exports",
&outbox,
)
.unwrap();
let queue = pending(&outbox);
assert_eq!(queue.len(), 1);
assert_eq!(queue[0].remote_dir, "Exports");
assert_eq!(queue[0].name, "a.jpg");
}
#[test]
fn two_exports_of_the_same_name_both_survive_the_outbox() {
// Both were asked for and neither has uploaded, so the second must
// not overwrite the first while it waits.
let dir = tmp();
let outbox = dir.join("outbox");
place(
&encoded("a.jpg", b"one"),
ExportTarget::Remote,
"E",
&outbox,
)
.unwrap();
place(
&encoded("a.jpg", b"two"),
ExportTarget::Remote,
"E",
&outbox,
)
.unwrap();
let queue = pending(&outbox);
assert_eq!(queue.len(), 2);
// Both still claim the name they should arrive under; only the local
// staging name differs.
assert!(queue.iter().all(|p| p.name == "a.jpg"));
assert_ne!(queue[0].local, queue[1].local);
}
#[test]
fn a_payload_with_no_record_is_ignored() {
// The window a kill between the two writes leaves behind. It must not
// become an upload to nowhere.
let dir = tmp();
let outbox = dir.join("outbox");
std::fs::create_dir_all(&outbox).unwrap();
std::fs::write(outbox.join("orphan.jpg"), b"x").unwrap();
assert!(pending(&outbox).is_empty());
}
#[test]
fn a_record_with_no_payload_is_ignored() {
let dir = tmp();
let outbox = dir.join("outbox");
std::fs::create_dir_all(&outbox).unwrap();
std::fs::write(outbox.join("ghost.jpg.dest"), "E\nghost.jpg\n").unwrap();
assert!(pending(&outbox).is_empty());
}
#[test]
fn an_empty_outbox_is_not_an_error() {
// Called on every sync pass, including before anything is exported
// and on a device where the directory has never been created.
assert!(pending(Path::new("/nonexistent/darkroom/outbox")).is_empty());
assert_eq!(pending_count(Path::new("/nonexistent/darkroom/outbox")), 0);
}
#[test]
fn the_remote_path_joins_root_folder_and_name() {
let entry = Pending {
local: PathBuf::from("/tmp/a.jpg"),
remote_dir: "Exports/2026".into(),
name: "a.jpg".into(),
};
assert_eq!(
entry.remote_path("Photos").as_str(),
"Photos/Exports/2026/a.jpg"
);
assert_eq!(
entry.remote_folder("Photos").as_str(),
"Photos/Exports/2026"
);
}
#[test]
fn an_empty_folder_exports_to_the_library_root() {
// "Ask each time" is not set here — an empty remote folder means the
// root, and it must not produce a double slash the server rejects.
let entry = Pending {
local: PathBuf::from("/tmp/a.jpg"),
remote_dir: String::new(),
name: "a.jpg".into(),
};
assert_eq!(entry.remote_path("Photos").as_str(), "Photos/a.jpg");
}
#[test]
fn stray_slashes_do_not_produce_an_unusable_path() {
// The folder comes from a picker or a text field, and either can hand
// over a leading or trailing slash.
let entry = Pending {
local: PathBuf::from("/tmp/a.jpg"),
remote_dir: "/Exports/".into(),
name: "a.jpg".into(),
};
assert_eq!(
entry.remote_path("/Photos/").as_str(),
"Photos/Exports/a.jpg"
);
}
#[test]
fn the_status_line_never_claims_an_upload_that_has_not_happened() {
// A queued export is real and finished, but it is not on the server,
// and saying so before it is would be a promise the app cannot keep.
let queued = Placed::Queued {
local: PathBuf::from("/tmp/a.jpg"),
remote_dir: "Exports".into(),
};
let text = queued.describe();
assert!(text.contains("Queued"), "{text}");
assert!(!text.contains("Exported"), "{text}");
assert!(Placed::Device(PathBuf::from("/tmp/a.jpg"))
.describe()
.contains("Exported"));
}
}
+331 -10
View File
@@ -18,12 +18,14 @@ mod activity;
mod collections_ui;
mod derived_sync;
mod develop;
mod export;
mod labels;
mod library;
mod library_ui;
#[cfg(live_style)]
mod live_style;
mod net_runtime;
mod presets;
mod settings_store;
mod settings_ui;
mod trash;
@@ -286,6 +288,109 @@ fn sync_framing(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>
window.set_crop_h(crop.height);
}
/// TRACES: FR-EXP-6 | FR-EXP-9
/// Render the open image at full resolution and place the result.
///
/// Synchronous, on the UI thread, and that is a known compromise rather than
/// an oversight. A full-resolution render plus a Lanczos reduction plus an
/// encode is hundreds of milliseconds on a 24 MP frame, and the window is
/// unresponsive for all of it. It is done this way because the alternative —
/// moving a `DevelopSession` and its GPU pass onto a worker — is a larger
/// change than the button is worth before batch export exists, and a batch is
/// what makes the wait intolerable rather than merely noticeable. The status
/// line says what is happening in the meantime.
fn export_now(
window: &AppWindow,
session: &Rc<RefCell<Option<DevelopSession>>>,
settings: &Rc<settings_ui::SettingsController>,
library: &Rc<library_ui::LibraryController>,
) -> Result<export::Placed, String> {
let mut borrowed = session.borrow_mut();
let Some(session) = borrowed.as_mut() else {
return Err("nothing is open".into());
};
let stored = settings.snapshot();
let frame = session.render_for_export()?;
// The size has to be resolved before the name, because `{dimensions}` is
// one of the tokens the template can carry.
let (tw, th) = dr_export::target_size(
frame.width,
frame.height,
stored.export.sizing,
stored.export.allow_upscaling,
);
let filename = window.get_filename().to_string();
let stem = std::path::Path::new(&filename)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "export".into());
let outbox = match library.session() {
Some((_, s)) => export::outbox_dir(&s.server, &s.user_id),
// No account, so no outbox — a device export still works, and a
// remote one is refused below by `place` rather than here, so the
// message names the setting rather than the plumbing.
None => std::env::temp_dir().join("darkroom-outbox"),
};
let ctx = dr_export::NameContext {
source_stem: &stem,
sequence: 1,
date: "",
width: tw,
height: th,
preset: "",
};
// What counts as "taken" depends on where this is going. A device export
// can look at the folder; a queued one is checked against the outbox,
// since the server cannot be reached from here and may not be reachable
// at all — see `export::stage` for why two queued exports of one name
// both survive regardless.
let target_dir = std::path::PathBuf::from(&stored.export.destination);
let taken = |name: &str| -> bool {
match stored.export.target {
dr_types::ExportTarget::Device => target_dir.join(name).exists(),
dr_types::ExportTarget::Remote => false,
}
};
let name = dr_export::resolve_name(
&stored.export.filename_template,
&ctx,
stored.export.format,
stored.export.collision,
&taken,
)
.ok_or("a file of that name is already there, and the collision setting is Skip")?;
let encoded = dr_export::export(&frame, &stored.export, name).map_err(|e| e.to_string())?;
export::place(
&encoded,
stored.export.target,
&stored.export.destination,
&outbox,
)
}
/// What the export button should say, given where an export would go.
///
/// The label carries the destination because the button is the only place the
/// distinction is visible from: "Export" alone gives no hint whether the file
/// lands on this device or is queued for a server that may be unreachable.
fn refresh_export_label(window: &AppWindow, settings: &Rc<settings_ui::SettingsController>) {
let stored = settings.snapshot();
let label = match stored.export.target {
dr_types::ExportTarget::Device => "Export".to_string(),
dr_types::ExportTarget::Remote => "Export to Nextcloud".to_string(),
};
window.set_export_label(label.into());
}
/// Push current parameter values back to the interface.
///
/// The controls are not self-updating: the core clamps values, so what the
@@ -297,7 +402,55 @@ fn sync_framing(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>
/// progress. The symptom is a slider that jumps on click but cannot be
/// dragged, because each move event destroys the thing that would deliver
/// the next one.
fn sync_rows(
/// TRACES: FR-CAT-8
/// Apply a fetched sidecar to the open session, now or as soon as it arrives.
///
/// The sidecar fetch is started beside the image fetch and is three orders of
/// magnitude smaller, so it has almost always landed by the time there is a
/// session to apply it to — and this takes it straight from the channel. The
/// timer covers the case where it has not, which is why this is not simply a
/// blocking receive: a slow or stalled sidecar request must not freeze the
/// window with the photograph already decoded and on screen.
///
/// A late arrival redraws, so the image is correct either way; the only
/// difference is whether it was ever briefly shown at its defaults.
fn apply_when_ready(
window: &AppWindow,
rx: Rc<std::sync::mpsc::Receiver<Option<dr_pipeline::Sidecar>>>,
session: &Rc<RefCell<Option<DevelopSession>>>,
rows: &Rc<slint::VecModel<ParamRow>>,
redraw: &Rc<dyn Fn(&AppWindow)>,
) {
// Already here — the overwhelmingly common case.
if let Ok(got) = rx.try_recv() {
if let Some(sidecar) = got {
presets::apply_stored_edit(window, &sidecar, session, rows);
}
return;
}
let weak = window.as_weak();
let session = session.clone();
let rows = rows.clone();
let redraw = redraw.clone();
let timer = Rc::new(slint::Timer::default());
let held = timer.clone();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(50),
move || {
let Ok(got) = rx.try_recv() else { return };
held.stop();
let Some(w) = weak.upgrade() else { return };
let Some(sidecar) = got else { return };
if presets::apply_stored_edit(&w, &sidecar, &session, &rows) {
redraw(&w);
}
},
);
}
pub(crate) fn sync_rows(
window: &AppWindow,
rows: &Rc<slint::VecModel<ParamRow>>,
session: &Rc<RefCell<Option<DevelopSession>>>,
@@ -451,6 +604,14 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
#[allow(clippy::type_complexity)]
let open_from_library: Rc<RefCell<Option<Rc<dyn Fn(String)>>>> = Rc::new(RefCell::new(None));
// TRACES: FR-CAT-8
// The same knot, for the other direction: leaving develop has to persist
// the edit, and the grid's "‹ Library" button is wired before the develop
// session exists to save from. Empty until then, and calling it is a no-op
// rather than a panic — there is nothing open to lose.
#[allow(clippy::type_complexity)]
let leave_develop: Rc<RefCell<Option<Rc<dyn Fn()>>>> = Rc::new(RefCell::new(None));
// Before anything binds to a token: the compiled palette is already in
// place, so this only overwrites what style.yaml currently says.
#[cfg(live_style)]
@@ -463,6 +624,10 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// develop side reads `paths` to rebuild its browsing list when an image is
// opened from the grid.
let library = library_ui::LibraryController::new(activity.clone());
// Hoisted out of the launch block below because the settings clipboard
// needs it too: a paste onto "the selection" reads the selection from
// here, and that wiring happens once the develop session exists.
let collections = collections_ui::CollectionsController::new(activity.clone());
// 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
@@ -473,7 +638,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
window.set_show_launch(startup == launch::Startup::ShowLaunchScreen);
let library = library.clone();
let collections = collections_ui::CollectionsController::new(activity.clone());
let collections = collections.clone();
// The click handler needs `show`, which is built further down because
// it captures the develop session and the GPU context. This cell is
@@ -481,13 +646,24 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// A click before then is a no-op rather than a panic — the grid cannot
// be reached until the window is running, by which point it is set.
let open_from_library = open_from_library.clone();
library_ui::wire(&window, library.clone(), collections.clone(), move |path| {
let Some(f) = open_from_library.borrow().clone() else {
log::warn!("open requested before the viewer was ready: {path}");
return;
};
f(path);
});
let leave_develop = leave_develop.clone();
library_ui::wire(
&window,
library.clone(),
collections.clone(),
move |path| {
let Some(f) = open_from_library.borrow().clone() else {
log::warn!("open requested before the viewer was ready: {path}");
return;
};
f(path);
},
Rc::new(move || {
if let Some(f) = leave_develop.borrow().clone() {
f();
}
}),
);
// The collections sidebar shares the library's catalog handle rather
// than opening its own: one SQLite connection, so an edit here is
@@ -567,14 +743,20 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// Wired independently of every view above. It reads no library and holds no
// session, so it has nothing to be sequenced against — which is the reason
// it is a page reachable from anywhere rather than a panel inside one view.
// Hoisted out of the block below: the export action needs the same record
// the settings page edits, and a controller scoped to the wiring block
// would be gone by the time that callback is built.
let settings = settings_ui::SettingsController::new();
{
let settings = settings_ui::SettingsController::new();
// What the cache actually holds, so the ceiling above it is a figure
// the user can judge rather than an abstract one.
settings.set_usage_label(describe_cache_usage(&library));
// Rendered once up front so the page is correct the first time it is
// opened, rather than on the second open after a callback has run.
settings_ui::render(&window, &settings);
// The export button carries its destination, so it has to be correct
// before the first click rather than after the first settings edit.
refresh_export_label(&window, &settings);
// Apply what is on disk before anything can use it. Without this the
// controller's defaults stand until the user happens to open the
@@ -595,6 +777,9 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// cache would sit over budget indefinitely.
lib.set_cache_budget(s.cache.original_budget_bytes);
lib.set_keep_opened_originals(s.cache.keep_opened_originals);
if let Some(w) = weak.upgrade() {
refresh_export_label(&w, &ctl);
}
// Lowering the ceiling evicts, so the figure beside it has just
// changed — leaving the old one would show the cache still over a
@@ -627,6 +812,16 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// The current develop session, if the file yielded sensor data.
let session: Rc<RefCell<Option<DevelopSession>>> = Rc::new(RefCell::new(None));
// TRACES: FR-DEV-6 | FR-CAT-8
// The settings clipboard, and where the open image's edit is stored.
//
// Both live for the life of the window rather than the view: a copy is
// taken in develop and may be pasted onto a selection back in the grid, so
// a clipboard owned by the develop view would be emptied by the very
// navigation that carries it to its destination.
let clipboard = presets::Clipboard::new();
let open_image: presets::OpenImage = Rc::new(RefCell::new(presets::Stored::Nowhere));
// One model for the lifetime of the window. Rows are mutated in place;
// see `sync_rows` for why replacing it breaks dragging.
let rows: Rc<slint::VecModel<ParamRow>> = Rc::new(slint::VecModel::default());
@@ -793,7 +988,14 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let redraw = redraw.clone();
let gpu = gpu.clone();
let rows = rows.clone();
let open_image = open_image.clone();
let library_for_show = library.clone();
Rc::new(move |window: &AppWindow| {
// The image about to be replaced is the last chance to persist its
// edit — stepping to the next frame is as much a departure as
// going back to the grid.
presets::save_open_edit(window, &open_image.borrow(), &session, &library_for_show);
let i = *index.borrow();
// Cloned rather than held: `load` below is slow, and keeping the
// list borrowed across it would panic the moment anything else
@@ -825,6 +1027,15 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
Some(s) => {
window.set_adjust_enabled(true);
*session.borrow_mut() = Some(s);
// A local file stores its edit beside itself. Read
// *before* the first render, so an edited
// photograph never flashes up at its defaults.
*open_image.borrow_mut() = presets::Stored::Local(path.to_path_buf());
if let Some(sidecar) = presets::load_local(path) {
presets::apply_stored_edit(window, &sidecar, &session, &rows);
}
// Through `sync_rows` rather than setting rows
// directly, so the curve's drawn shape is
// refreshed by the same path that refreshes the
@@ -837,6 +1048,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// the controls rather than offering sliders that
// would do nothing.
*session.borrow_mut() = None;
*open_image.borrow_mut() = presets::Stored::Nowhere;
rows.set_vec(Vec::<ParamRow>::new());
window.set_adjust_enabled(false);
if let Some(image) = l.fallback {
@@ -874,9 +1086,14 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let rows = rows.clone();
let gpu = gpu.clone();
let activity = activity.clone();
let open_image = open_image.clone();
*open_from_library.borrow_mut() = Some(Rc::new(move |path: String| {
let Some(w) = weak.upgrade() else { return };
// Whatever was open before is being replaced; persist its edit
// before the identity below is overwritten.
presets::save_open_edit(&w, &open_image.borrow(), &session, &library);
let name = path.rsplit('/').next().unwrap_or(&path).to_string();
reset_view_state(&w);
w.set_filename(name.clone().into());
@@ -894,6 +1111,32 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
return;
};
// TRACES: FR-CAT-8 | FR-NC-8
// Where this image's edit belongs. The uuid comes from the catalog
// so every device names the same version; without one there is
// nowhere to save to, and the image opens read-only as far as
// persistence is concerned rather than writing to an invented
// identity that would never merge.
*open_image.borrow_mut() = match library.version_uuid_for_path(&path) {
Some(version_uuid) => presets::Stored::Remote {
path: path.clone(),
version_uuid,
},
None => {
log::debug!("no catalog version for {path}; edits will not persist");
presets::Stored::Nowhere
}
};
// The sidecar is fetched alongside the image rather than after it.
// It is a few kilobytes against tens of megabytes, so it costs
// nothing to have in hand by the time there is a session to apply
// it to — and starting it here means the edit is ready when the
// photograph is, instead of the image appearing at its defaults
// and visibly changing a moment later.
let sidecar_rx =
library::spawn_sidecar_fetch(creds.clone(), user_id.clone(), path.clone());
// TRACES: FR-NC-6a
// The cache is consulted first, so a second open of the same
// photograph is a disk read rather than a second download of tens
@@ -924,6 +1167,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let redraw = redraw.clone();
let rows = rows.clone();
let gpu = gpu.clone();
let sidecar_rx = Rc::new(sidecar_rx);
let timer = Rc::new(slint::Timer::default());
let held = timer.clone();
timer.start(
@@ -967,6 +1211,20 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
Some(s) => {
w.set_adjust_enabled(true);
*session.borrow_mut() = Some(s);
// TRACES: FR-CAT-8
// The stored edit, if it has landed. It
// was started before the download of a
// file thousands of times its size, so in
// practice it has; `apply_when_ready`
// covers the case where it has not rather
// than blocking the UI thread on a socket.
apply_when_ready(
&w,
sidecar_rx.clone(),
&session,
&rows,
&redraw,
);
sync_rows(&w, &rows, &session);
redraw(&w);
}
@@ -993,10 +1251,73 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
}));
}
// ---- copying settings between photographs (FR-DEV-6) ----------------
//
// Wired after the develop session and the library both exist, because a
// paste reaches both: onto the image on screen, or onto the grid's
// selection through its sidecars.
{
presets::wire(
&window,
clipboard.clone(),
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
// "‹ Library" button was wired before there was a session to save.
let weak = window.as_weak();
let open_image = open_image.clone();
let session = session.clone();
let library = library.clone();
*leave_develop.borrow_mut() = Some(Rc::new(move || {
let Some(w) = weak.upgrade() else { return };
presets::save_open_edit(&w, &open_image.borrow(), &session, &library);
}));
presets::render(&window, &clipboard, &settings);
}
// ---- Adjustment callbacks ------------------------------------------
//
// Generic by construction: they carry indices into the capability list,
// so adding an operation needs no change here (FR-DEV-3c).
// --- export ---------------------------------------------------------
//
// Renders its own frame at full resolution rather than encoding what is
// on screen: the display render is deliberately viewport-sized
// (FR-DSP-1), and exporting that would hand the user a soft, screen-sized
// file with no indication anything had been lost (FR-EXP-9).
{
let weak = window.as_weak();
let session = session.clone();
let settings = settings.clone();
let library = library.clone();
window.on_export_image(move || {
let Some(w) = weak.upgrade() else { return };
w.set_export_busy(true);
// Pushed before the work rather than after: the render blocks the
// UI thread, so a label set afterwards would never be drawn in
// the "Exporting…" state at all.
w.set_export_status("Rendering…".into());
let result = export_now(&w, &session, &settings, &library);
match result {
Ok(placed) => w.set_export_status(placed.describe().into()),
Err(e) => w.set_export_status(format!("Export failed: {e}").into()),
}
w.set_export_busy(false);
refresh_export_label(&w, &settings);
});
}
{
let weak = window.as_weak();
let session = session.clone();
+129 -14
View File
@@ -286,17 +286,45 @@ fn flag_code(f: dr_types::FlagState) -> i64 {
}
}
/// TRACES: FR-CAT-8 | FR-NC-8 | FR-CULL-4
/// One image's judgement, on its way to a sidecar.
/// TRACES: FR-CAT-8 | FR-NC-8 | FR-CULL-4 | FR-DEV-6
/// One amendment to one image's sidecar, on its way to the server.
#[derive(Debug, Clone)]
pub struct JudgementWrite {
pub struct SidecarWrite {
/// Remote path of the *image*. The sidecar sits beside it, with the
/// extension replaced — that adjacency is what makes a sidecar findable
/// without an index (ARCH §6.12).
pub image_path: String,
pub version_uuid: String,
pub rating: u8,
pub flag: u8,
pub amendment: Amendment,
}
/// What a write changes about the version it names.
///
/// An enum rather than a struct of optional fields because the two are written
/// by different actions with different failure costs, and because a write must
/// never carry a *stale* copy of what it is not changing. A settings write that
/// also carried a rating would have to have read one from somewhere, and the
/// obvious somewhere — the catalog, moments earlier — is exactly how a cull
/// made between the read and the write gets silently reverted.
///
/// Everything not named by the variant is left as the file had it, which is
/// what makes the read-modify-write in [`write_one_sidecar`] a genuine
/// amendment rather than a replacement.
#[derive(Debug, Clone)]
pub enum Amendment {
/// A star rating and a pick/reject flag — the cull.
Judgement { rating: u8, flag: u8 },
/// TRACES: FR-DEV-6
/// Copied develop settings, applied within `scope`.
///
/// Carries the [`Scope`] rather than a pre-filtered preset so the target's
/// own framing can be spared *at the file*: excluding framing means
/// leaving the keys already in the sidecar untouched, which cannot be
/// expressed by the parameter list alone.
Settings {
preset: dr_pipeline::Preset,
scope: dr_pipeline::Scope,
},
}
/// Where an image's sidecar lives.
@@ -316,7 +344,7 @@ pub fn sidecar_path(image_path: &str) -> String {
format!("{stem}.{}", dr_pipeline::sidecar::EXTENSION)
}
/// Persist judgements to sidecars beside their images.
/// Persist amendments to sidecars beside their images.
///
/// # Why this reads before it writes
///
@@ -337,7 +365,7 @@ pub fn sidecar_path(image_path: &str) -> String {
pub fn spawn_sidecar_writes(
creds: AppCredentials,
user_id: String,
writes: Vec<JudgementWrite>,
writes: Vec<SidecarWrite>,
) -> Receiver<SidecarMessage> {
let (tx, rx) = std::sync::mpsc::channel();
@@ -406,7 +434,7 @@ pub enum SidecarMessage {
}
/// Read-modify-write one sidecar.
async fn write_one_sidecar(backend: &NextcloudBackend, w: &JudgementWrite) -> Result<(), String> {
async fn write_one_sidecar(backend: &NextcloudBackend, w: &SidecarWrite) -> Result<(), String> {
let path = RemotePath::new(sidecar_path(&w.image_path));
let id = RemoteId::Path(path.clone());
@@ -444,7 +472,7 @@ async fn write_one_sidecar(backend: &NextcloudBackend, w: &JudgementWrite) -> Re
}
}
// Amend the version this judgement belongs to, creating it if the file did
// Amend the version this write belongs to, creating it if the file did
// not have one. The uuid comes from the catalog, so the same photograph
// keeps one identity across devices (FR-NC-8).
let mut version = sidecar
@@ -459,11 +487,24 @@ async fn write_one_sidecar(backend: &NextcloudBackend, w: &JudgementWrite) -> Re
..Default::default()
});
version.rating = w.rating;
version.flag = w.flag;
// A judgement is an edit as far as the merge is concerned: without the
// bump, a device that rated the same frame earlier would win on revision
// and this rating would be discarded at the next sync (FR-NC-9).
// Only what the amendment names. Everything else in the version — the
// rating a settings write must not touch, the crop an adjustments-only
// paste must spare, the unknown keys of an operation this build lacks —
// survives because it was read from the file and is written back.
match &w.amendment {
Amendment::Judgement { rating, flag } => {
version.rating = *rating;
version.flag = *flag;
}
Amendment::Settings { preset, scope } => {
preset.amend(&mut version.params, *scope);
}
}
// A judgement is an edit as far as the merge is concerned, and so is a
// paste: without the bump, a device that touched the same frame earlier
// would win on revision and this write would be discarded at the next sync
// (FR-NC-9).
version.revision = version.revision.saturating_add(1);
version.modified = now_secs();
@@ -1031,6 +1072,80 @@ pub struct CacheContext {
///
/// Returns the bytes on a channel rather than blocking: the download runs on
/// its own thread and the UI stays live, exactly as thumbnail fetching does.
/// TRACES: FR-CAT-8 | FR-DEV-6
/// Fetch and parse the sidecar beside one image.
///
/// # Why the edit is read from the file rather than the catalog
///
/// The catalog carries a `graph_hash` and no parameters, and it is
/// *disposable* (ARCH §6.12) — a rebuild would silently return every
/// photograph to neutral. The sidecar is the authoritative store, so it is
/// what an open reads, and that is also what makes an edit pasted on the
/// desktop appear when the same frame is opened on the phone.
///
/// # Why absence and failure are the same answer here
///
/// `None` means "open this image at its defaults", which is right for a
/// photograph that has never been edited — the overwhelmingly common case on a
/// fresh library — and equally right when the network is down. The alternative,
/// refusing to open the image because its sidecar could not be read, would make
/// an unreachable server also mean an unviewable library.
///
/// The one case that is *not* harmless is a sidecar that exists but does not
/// parse. That still opens at defaults, but the write path
/// ([`write_one_sidecar`]) independently refuses to overwrite a file it could
/// not read, so an edit this build failed to understand is never destroyed by
/// having been opened.
pub fn spawn_sidecar_fetch(
creds: AppCredentials,
user_id: String,
image_path: String,
) -> Receiver<Option<dr_pipeline::Sidecar>> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let rt = match crate::net_runtime::build() {
Ok(e) => e,
Err(e) => {
log::debug!("sidecar fetch runtime: {e}");
let _ = tx.send(None);
return;
}
};
rt.block_on(async {
let backend = match NextcloudBackend::new(&creds, &user_id) {
Ok(b) => b,
Err(e) => {
log::debug!("sidecar fetch backend: {e}");
let _ = tx.send(None);
return;
}
};
let path = RemotePath::new(sidecar_path(&image_path));
let id = RemoteId::Path(path.clone());
// A 404 is the normal case on a library that has never been
// edited, so this is `ok()` rather than an error path.
let parsed = backend.get(&id, None).await.ok().and_then(|bytes| {
let text = String::from_utf8_lossy(&bytes).into_owned();
match dr_pipeline::Sidecar::parse(&text) {
Ok(s) => Some(s),
Err(e) => {
log::warn!("sidecar at {} is unreadable ({e})", path.as_str());
None
}
}
});
let _ = tx.send(parsed);
});
});
rx
}
pub fn spawn_full_fetch(
creds: AppCredentials,
user_id: String,
+200 -6
View File
@@ -314,6 +314,38 @@ impl LibraryController {
.map(|id| dr_types::ImageId(*id as u64))
}
/// TRACES: FR-NC-8 | FR-DEV-6
/// The default version's uuid for an image, by its remote path.
///
/// Taken from the catalog rather than generated, and rather than read off
/// whatever the sidecar happens to contain. The uuid is the identity a
/// cross-device merge keys on (FR-NC-8): a develop session that invented
/// one would write a *second* version beside the one the cull is stored
/// in, and the photograph would arrive on the other device holding two
/// edits that never merge.
///
/// Creates the version if the image has none, by the same route a rating
/// does — see `dr_catalog::rating::default_version_id` for why an image
/// can legitimately arrive without one.
pub fn version_uuid_for_path(&self, path: &str) -> Option<String> {
let image = self.image_id_for_path(path)?;
let borrow = self.catalog.borrow();
let catalog = borrow.as_ref()?;
let conn = catalog.connection();
let id = match dr_catalog::rating::default_version_id(conn, image) {
Ok(id) => id,
Err(e) => {
log::debug!("no version for {path}: {e}");
return None;
}
};
conn.query_row("SELECT uuid FROM versions WHERE id = ?1", [id], |r| {
r.get(0)
})
.ok()
}
/// TRACES: FR-NC-6a
/// Where cached originals live for the open library.
///
@@ -1379,6 +1411,118 @@ fn judgement_summary(n: usize, rating: Option<u8>, flag: Option<dr_types::FlagSt
}
}
/// TRACES: FR-DEV-6
/// Apply copied develop settings to a selection of images.
///
/// # Why this goes straight to the sidecars
///
/// The sidecar is the authoritative store for an edit (ARCH §6.12) and the
/// catalog holds no parameters at all — only a `graph_hash` — so there is
/// nothing here for the catalog to record. Nor is any image opened: applying
/// to forty frames by loading forty develop sessions would mean forty RAW
/// downloads and forty demosaics to move some numbers between two maps, which
/// is not a thing to ask of a phone. See [`crate::presets`].
///
/// # What the user sees
///
/// Nothing in the grid changes — a thumbnail is rendered from the server's
/// preview and does not reflect an edit — so the status line is the only
/// confirmation, exactly as it is for a bulk judgement. The applied settings
/// appear when a target is next opened in develop, which is what reads the
/// sidecar back.
pub fn paste_settings_to_selection(
window: &AppWindow,
ctl: &Rc<LibraryController>,
images: &[dr_types::ImageId],
preset: &dr_pipeline::Preset,
scope: dr_pipeline::Scope,
) {
if images.is_empty() {
// Said out loud rather than ignored, matching what a judgement
// keystroke does with an empty selection.
window.set_library_status("Select an image first".into());
return;
}
let writes = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
// A never-judged image may have no version row yet, and the query
// below joins on one. Ratings create them as a side effect; a paste
// is the first write path that can reach an image which has never
// been rated, so it has to ask for them itself.
if let Err(e) = dr_catalog::rating::ensure_default_versions(catalog.connection()) {
log::debug!("ensuring versions before a paste: {e}");
}
collect_settings_writes(catalog, images, preset, scope)
};
if writes.is_empty() {
window.set_library_error("Could not find those images in the catalog.".into());
return;
}
let count = writes.len();
window.set_library_status(
format!(
"Applied settings to {count} image{}.",
if count == 1 { "" } else { "s" }
)
.into(),
);
start_sidecar_writes(window, ctl, writes);
}
/// Gather one settings write per image, addressed by remote path and version.
///
/// The uuid comes from the catalog for the same reason a judgement's does: it
/// is the identity a cross-device merge keys on, and a generated one would
/// write a second version beside the one the image already has (FR-NC-8).
fn collect_settings_writes(
catalog: &Catalog,
images: &[dr_types::ImageId],
preset: &dr_pipeline::Preset,
scope: dr_pipeline::Scope,
) -> Vec<library::SidecarWrite> {
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT i.source_ref, v.uuid
FROM images i
JOIN versions v ON v.image_id = i.id AND v.is_default = 1
WHERE i.id IN ({placeholders})"
);
let params: Vec<rusqlite::types::Value> = images
.iter()
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
.collect();
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
return Vec::new();
};
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(library::SidecarWrite {
image_path: r.get(0)?,
version_uuid: r.get(1)?,
amendment: library::Amendment::Settings {
preset: preset.clone(),
scope,
},
})
});
match rows {
Ok(rows) => rows.flatten().collect(),
Err(e) => {
log::debug!("collecting settings writes: {e}");
Vec::new()
}
}
}
/// Gather what the sidecar writer needs for each judged image.
///
/// The version uuid comes from the catalog rather than being generated here:
@@ -1388,7 +1532,7 @@ fn judgement_summary(n: usize, rating: Option<u8>, flag: Option<dr_types::FlagSt
fn collect_sidecar_writes(
catalog: &Catalog,
images: &[dr_types::ImageId],
) -> Vec<library::JudgementWrite> {
) -> Vec<library::SidecarWrite> {
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
@@ -1407,11 +1551,13 @@ fn collect_sidecar_writes(
return Vec::new();
};
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(library::JudgementWrite {
Ok(library::SidecarWrite {
image_path: r.get(0)?,
version_uuid: r.get(1)?,
rating: r.get::<_, i64>(2)? as u8,
flag: r.get::<_, i64>(3)? as u8,
amendment: library::Amendment::Judgement {
rating: r.get::<_, i64>(2)? as u8,
flag: r.get::<_, i64>(3)? as u8,
},
})
});
@@ -1425,10 +1571,10 @@ fn collect_sidecar_writes(
}
/// Push judgements out to sidecars on a worker, reporting once at the end.
fn start_sidecar_writes(
pub(crate) fn start_sidecar_writes(
window: &AppWindow,
ctl: &Rc<LibraryController>,
writes: Vec<library::JudgementWrite>,
writes: Vec<library::SidecarWrite>,
) {
if writes.is_empty() {
return;
@@ -1842,6 +1988,45 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
.unwrap_or_else(std::env::temp_dir);
let _ = std::fs::create_dir_all(&scratch);
// TRACES: FR-EXP-7 | FR-NC-10
// Drain the export outbox on the same pass, and before the shards. An
// export the user was told had succeeded is waiting here, and it is the
// one thing in this directory that exists nowhere else — a thumbnail
// shard can be rebuilt from the originals, and the catalog is an index.
//
// Fire-and-forget rather than reported: it runs on its own thread and
// clears entries as they land, so a partial run leaves the rest queued
// for next time and nothing is lost by not watching it. It reports
// through the log until an export has a place in the activity list.
{
let outbox = crate::export::outbox_dir(&session.server, &session.user_id);
if crate::export::pending_count(&outbox) > 0 {
let rx = crate::export::spawn_upload(
creds.clone(),
session.user_id.clone(),
session.root.clone(),
outbox,
);
std::thread::spawn(move || {
while let Ok(msg) = rx.recv() {
match msg {
crate::export::UploadMessage::Status(s) => log::info!("export: {s}"),
crate::export::UploadMessage::Finished {
uploaded,
remaining,
error,
} => {
log::info!("export: {uploaded} uploaded, {remaining} still queued");
if let Some(e) = error {
log::warn!("export upload stopped: {e}");
}
}
}
}
});
}
}
window.set_library_syncing(true);
let rx = crate::derived_sync::spawn_sync(
creds,
@@ -2448,6 +2633,7 @@ pub fn wire<F>(
ctl: Rc<LibraryController>,
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
on_open_image: F,
on_leave_develop: Rc<dyn Fn()>,
) where
F: Fn(String) + 'static,
{
@@ -2806,6 +2992,14 @@ pub fn wire<F>(
window.on_back_to_library(move || {
let Some(w) = weak.upgrade() else { return };
// TRACES: FR-CAT-8
// The edit is persisted on the way out rather than on every
// slider move: a save is a network round-trip, and one per drag
// frame would put an upload inside the gesture NFR-P5 governs.
// This is the moment the image stops being the open one, so it is
// the last moment its edit can be written.
on_leave_develop();
let resume = ctl.resume_at.get();
if resume > 0 {
// Through the same channel a scrub uses, and for the same
+653
View File
@@ -0,0 +1,653 @@
//! TRACES: FR-DEV-6
//! Copying develop settings from one photograph to others.
//!
//! # The three pieces
//!
//! * A [`Clipboard`] — one [`Preset`] held for the life of the window. Not
//! the system clipboard: these are typed values addressed by `op.param`,
//! and putting them on a text clipboard would mean anything that happened
//! to be copied afterwards silently disarmed the paste.
//! * An [`OpenImage`] — where the develop view's edit is *stored*, which is
//! what a copy is taken from and what a paste has to be written back to.
//! * The batch, in [`paste_to_selection`], which never opens an image at all.
//!
//! # Why a paste can reach images that are not open
//!
//! Applying settings to forty frames by opening forty develop sessions would
//! mean forty downloads of a whole RAW file and forty demosaics, to change
//! some numbers. The edit is *data* — [`Preset::amend`] operates on the
//! parameter map a sidecar already stores — so the batch is a read-modify-write
//! per file and never touches a GPU. That is what makes it usable on a phone.
//!
//! The cost is that a batch paste is only visible once the target is opened
//! and its sidecar read, which is the load path this module also provides.
//!
//! # Two stores, because there are two ways an image is opened
//!
//! An image from the library grid lives on the server and its sidecar goes
//! beside it, through the same writer a rating uses. An image named on the
//! command line is a local file with no library behind it, and its sidecar is
//! written beside it on disk. Both are the same document in the same format —
//! only the transport differs, which is why [`Stored`] is an enum over where
//! rather than two notions of what.
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use dr_pipeline::{Preset, Scope, Sidecar};
use slint::ComponentHandle;
use crate::develop::DevelopSession;
use crate::{library, library_ui, settings_ui, AppWindow, ParamRow};
/// Where the develop view's current edit is stored.
///
/// `None` is not an error state: an image that failed to decode, or one opened
/// before a library was chosen, has nowhere to persist to and simply does not.
/// Copy still works from it — the graph is in memory either way — and it is
/// only the *saving* that has no destination.
#[derive(Debug, Clone, Default)]
pub enum Stored {
#[default]
Nowhere,
/// A file on this device, named on the command line.
Local(PathBuf),
/// An image in the library. The uuid comes from the catalog so that this
/// device and every other name the same version (FR-NC-8).
Remote { path: String, version_uuid: String },
}
/// Which image the develop view is showing, and where its edit belongs.
pub type OpenImage = Rc<RefCell<Stored>>;
/// The settings clipboard.
///
/// Holds the preset at full scope; the [`Scope`] is applied at paste time from
/// the user's setting, so changing that setting after copying takes effect on
/// the next paste rather than requiring a fresh copy.
#[derive(Default)]
pub struct Clipboard {
preset: RefCell<Option<Preset>>,
}
impl Clipboard {
pub fn new() -> Rc<Self> {
Rc::new(Self::default())
}
/// Take a copy of a session's edit.
pub fn copy_from(&self, session: &DevelopSession) {
self.put(session.copy_settings());
}
/// Hold an already-captured preset.
pub fn put(&self, preset: Preset) {
*self.preset.borrow_mut() = Some(preset);
}
/// The held preset, if there is one.
pub fn peek(&self) -> Option<Preset> {
self.preset.borrow().clone()
}
/// Whether anything has been copied.
///
/// True even for a *neutral* copy. Copying an unedited photograph and
/// pasting it onto an edited one is a meaningful action — it clears the
/// target — so "has something been copied" is a different question from
/// "does the copy contain any values", and this is the first.
pub fn is_armed(&self) -> bool {
self.preset.borrow().is_some()
}
/// Whether the held preset carries framing that the current scope drops.
///
/// What the interface uses to explain a setting's effect on *this*
/// clipboard rather than in the abstract.
pub fn holds_framing(&self) -> bool {
self.preset
.borrow()
.as_ref()
.is_some_and(|p| p.touches_framing())
}
/// A short description of what would be pasted, for a button's label.
///
/// Counts operations rather than parameters: the colour mixer alone
/// declares thirty-six, and "36 settings" would say something about the
/// mixer's shape rather than about how much was copied.
pub fn describe(&self, scope: Scope) -> String {
let Some(preset) = self.preset.borrow().clone() else {
return String::new();
};
match preset.op_count(scope) {
0 => "Neutral".to_string(),
1 => "1 adjustment".to_string(),
n => format!("{n} adjustments"),
}
}
}
/// The scope a paste should use, from the user's preference.
///
/// One function rather than the boolean read at each call site, so "the
/// setting is off by default and means framing stays behind" is stated once.
pub fn scope_for(settings: &dr_types::Settings) -> Scope {
if settings.develop.copy_includes_framing {
Scope::Everything
} else {
Scope::Adjustments
}
}
// ---------------------------------------------------------------------------
// Local sidecars
// ---------------------------------------------------------------------------
/// The sidecar path for a local image — the file's own path with the
/// extension replaced.
///
/// The same rule [`crate::library::sidecar_path`] applies to remote paths, so
/// a folder synced between the two is read identically from either side.
pub fn local_sidecar_path(image: &Path) -> PathBuf {
image.with_extension(dr_pipeline::sidecar::EXTENSION)
}
/// Read a local sidecar, if there is one.
///
/// Absence is the common case and is not an error. An *unreadable* file yields
/// `None` too, and [`save_local`] independently refuses to overwrite one — so
/// a sidecar this build cannot parse costs the edit being shown, never the
/// edit being kept.
pub fn load_local(image: &Path) -> Option<Sidecar> {
let path = local_sidecar_path(image);
let text = std::fs::read_to_string(&path).ok()?;
match Sidecar::parse(&text) {
Ok(s) => Some(s),
Err(e) => {
log::warn!("sidecar at {} is unreadable ({e})", path.display());
None
}
}
}
/// Read-modify-write a local sidecar.
///
/// Read first for the same reason the remote writer does: the file may already
/// hold a rating, or an operation this build does not know about, and writing
/// a fresh document containing only the current edit would delete both.
pub fn save_local(image: &Path, preset: &Preset, scope: Scope) -> Result<(), String> {
let path = local_sidecar_path(image);
// Distinguish "no sidecar yet" from "a sidecar this build cannot read".
// Only the second is a refusal — overwriting it would destroy an edit we
// merely failed to understand.
let existing = std::fs::read_to_string(&path).ok();
let mut sidecar = match existing.as_deref() {
None => Sidecar::new(),
Some(text) => Sidecar::parse(text).map_err(|e| {
format!(
"existing sidecar at {} is unreadable ({e}); not overwriting",
path.display()
)
})?,
};
// A local file has no catalog behind it to supply a version identity, so
// the file's own default version is used and one is created if absent.
// Deterministic rather than random: reopening the same photograph must
// amend the version it wrote last time, not accumulate one per save.
let uuid = sidecar
.default_version()
.map(|v| v.uuid.clone())
.unwrap_or_else(|| "local".to_string());
let mut version =
sidecar
.versions
.get(&uuid)
.cloned()
.unwrap_or_else(|| dr_pipeline::sidecar::Version {
uuid: uuid.clone(),
name: "Default".to_string(),
is_default: true,
revision: 0,
..Default::default()
});
preset.amend(&mut version.params, scope);
version.revision = version.revision.saturating_add(1);
version.modified = now_secs();
sidecar.put(version);
let text = sidecar.to_text();
// Write and rename, so an interrupted save cannot truncate an edit that
// was already safely on disk.
let tmp = path.with_extension("drsc.tmp");
std::fs::write(&tmp, text).map_err(|e| e.to_string())?;
std::fs::rename(&tmp, &path).map_err(|e| e.to_string())
}
// ---------------------------------------------------------------------------
// Saving and loading the open image
// ---------------------------------------------------------------------------
/// Persist the develop view's current edit to wherever it belongs.
///
/// Called when the image is about to stop being the open one — on leaving for
/// the library, on stepping to the next frame, and on closing the window —
/// rather than on every slider move. A save is a network round-trip on the
/// library path, and one per drag frame would put an upload inside the gesture
/// that NFR-P5 is about.
///
/// Silent on success and logged on failure, deliberately. There is no
/// acknowledgement worth interrupting a photographer for, and the failure that
/// matters — a sidecar this build could not parse — is refused by the writer
/// rather than resolved here.
pub fn save_open_edit(
window: &AppWindow,
stored: &Stored,
session: &Rc<RefCell<Option<DevelopSession>>>,
library: &Rc<library_ui::LibraryController>,
) {
let Some(preset) = session.borrow().as_ref().map(|s| s.copy_settings()) else {
return;
};
match stored {
// Nothing to save to. An image that failed to decode, or one opened
// before a library was chosen.
Stored::Nowhere => {}
Stored::Local(path) => {
// `Everything`: this is the image's *own* edit being written back,
// not a paste onto someone else's. Excluding framing here would
// make a crop the one adjustment that never survived a restart.
if let Err(e) = save_local(path, &preset, Scope::Everything) {
log::warn!("saving {}: {e}", path.display());
}
}
Stored::Remote { path, version_uuid } => {
let write = library::SidecarWrite {
image_path: path.clone(),
version_uuid: version_uuid.clone(),
amendment: library::Amendment::Settings {
preset,
scope: Scope::Everything,
},
};
library_ui::start_sidecar_writes(window, library, vec![write]);
}
}
}
/// Load a stored edit into the open session and refresh the panel.
///
/// Returns whether anything was applied, so the caller can skip a redraw for
/// the common case of a photograph that has never been edited.
pub fn apply_stored_edit(
window: &AppWindow,
sidecar: &Sidecar,
session: &Rc<RefCell<Option<DevelopSession>>>,
rows: &Rc<slint::VecModel<ParamRow>>,
) -> bool {
let Some(version) = sidecar.default_version() else {
return false;
};
{
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return false };
s.apply_version(version);
}
crate::sync_rows(window, rows, session);
true
}
// ---------------------------------------------------------------------------
// Wiring
// ---------------------------------------------------------------------------
/// Push the clipboard's state onto the window.
///
/// One function rather than three `set_` calls at each site, because the three
/// properties have to agree: a summary describing a scope the button is not
/// using would be worse than no summary.
pub fn render(
window: &AppWindow,
clipboard: &Rc<Clipboard>,
settings: &Rc<settings_ui::SettingsController>,
) {
let scope = scope_for(&settings.snapshot());
window.set_settings_armed(clipboard.is_armed());
window.set_settings_summary(clipboard.describe(scope).into());
// Only worth saying when it is actually true of *this* copy: a clipboard
// holding no crop loses nothing to the setting, and saying so anyway would
// train the user to ignore the line.
window.set_settings_framing_withheld(clipboard.holds_framing() && scope == Scope::Adjustments);
}
/// The develop view's shared state, as this module needs it.
///
/// Bundled rather than passed one by one because these four always travel
/// together — a paste changes the graph, so it must rebuild the panel, redraw
/// the canvas and know where to save, and a call site holding three of the
/// four is a call site with a bug in it.
#[derive(Clone)]
pub struct Develop {
pub session: Rc<RefCell<Option<DevelopSession>>>,
pub rows: Rc<slint::VecModel<ParamRow>>,
pub redraw: Rc<dyn Fn(&AppWindow)>,
/// Where the open image's edit is stored.
pub open: OpenImage,
}
/// Wire copy, paste and batch-paste.
pub fn wire(
window: &AppWindow,
clipboard: Rc<Clipboard>,
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;
// --- copy ------------------------------------------------------------
{
let weak = window.as_weak();
let clipboard = clipboard.clone();
let session = session.clone();
let settings = settings.clone();
window.on_copy_settings(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow().as_ref() {
clipboard.copy_from(s);
}
render(&w, &clipboard, &settings);
});
}
// --- paste onto the open image ---------------------------------------
{
let weak = window.as_weak();
let clipboard = clipboard.clone();
let session = session.clone();
let settings = settings.clone();
let rows = rows.clone();
let redraw = redraw.clone();
let open = open.clone();
let library = library.clone();
window.on_paste_settings(move || {
let Some(w) = weak.upgrade() else { return };
let Some(preset) = clipboard.peek() else {
return;
};
let scope = scope_for(&settings.snapshot());
{
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return };
s.apply_settings(&preset, scope);
}
// The sliders are showing the values that just moved, so the panel
// has to be rebuilt — a paste is the one edit that changes many
// controls without any of them having been touched.
crate::sync_rows(&w, &rows, &session);
redraw(&w);
// Saved immediately rather than on the way out. A paste is a
// deliberate, discrete action, unlike a drag, and the cost of one
// write is nothing beside the surprise of it not having stuck.
save_open_edit(&w, &open.borrow(), &session, &library);
});
}
// --- paste onto the selection ----------------------------------------
{
let weak = window.as_weak();
let clipboard = clipboard.clone();
let settings = settings.clone();
let library = library.clone();
let collections = collections.clone();
window.on_paste_settings_to_selection(move || {
let Some(w) = weak.upgrade() else { return };
let Some(preset) = clipboard.peek() else {
return;
};
let scope = scope_for(&settings.snapshot());
library_ui::paste_settings_to_selection(
&w,
&library,
&collections.selected(),
&preset,
scope,
);
});
}
}
/// 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,
/// which resolves on `revision` first anyway (FR-NC-9), and refusing to save
/// because a clock is unset would be far worse.
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use dr_pipeline::EditGraph;
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 edited() -> EditGraph {
use dr_pipeline::ops::exposure;
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.5);
g
}
#[test]
fn a_sidecar_sits_beside_its_image_with_the_extension_replaced() {
// Replaced, not appended, so a RAW and the JPEG beside it share one
// sidecar — they are the same photograph (FR-CAT-11).
assert_eq!(
local_sidecar_path(Path::new("/photos/a.CR2")),
PathBuf::from("/photos/a.drsc")
);
}
#[test]
fn an_edit_survives_a_save_and_a_load() {
use dr_pipeline::ops::exposure;
let dir = tempdir("round-trip");
let image = dir.join("a.CR2");
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap();
let sidecar = load_local(&image).expect("a sidecar was written");
let mut restored = EditGraph::default_chain();
sidecar
.default_version()
.expect("a version")
.apply(&mut restored);
assert_eq!(restored.param(exposure::ID, exposure::EXPOSURE), Some(1.5));
}
#[test]
fn an_image_with_no_sidecar_loads_as_nothing() {
let dir = tempdir("absent");
assert!(load_local(&dir.join("never-edited.CR2")).is_none());
}
#[test]
fn saving_twice_amends_one_version_rather_than_accumulating_them() {
// A random uuid per save would leave the file growing a version every
// time the user left the develop view, and `default_version` would
// start answering with whichever sorted first.
let dir = tempdir("one-version");
let image = dir.join("a.CR2");
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap();
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap();
assert_eq!(load_local(&image).expect("a sidecar").versions.len(), 1);
}
#[test]
fn a_save_bumps_the_revision() {
// FR-NC-9 resolves conflicts by revision; a write that did not bump it
// would lose to a stale remote copy at the next sync.
let dir = tempdir("revision");
let image = dir.join("a.CR2");
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap();
let first = load_local(&image)
.unwrap()
.default_version()
.unwrap()
.revision;
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap();
let second = load_local(&image)
.unwrap()
.default_version()
.unwrap()
.revision;
assert!(second > first, "{second} did not follow {first}");
}
#[test]
fn a_save_does_not_destroy_a_rating_it_never_read() {
// The read-modify-write property. A cull is stored in the same version
// as the edit, and leaving the develop view must not wipe it.
let dir = tempdir("keeps-rating");
let image = dir.join("a.CR2");
let mut sidecar = Sidecar::new();
sidecar.put(dr_pipeline::sidecar::Version {
uuid: "local".to_string(),
name: "Default".to_string(),
is_default: true,
rating: 4,
flag: 1,
..Default::default()
});
std::fs::write(local_sidecar_path(&image), sidecar.to_text()).unwrap();
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap();
let back = load_local(&image).unwrap();
let v = back.default_version().unwrap();
assert_eq!(v.rating, 4, "the cull was destroyed by an edit");
assert_eq!(v.flag, 1);
}
#[test]
fn a_save_refuses_to_overwrite_an_unreadable_sidecar() {
// The file may hold an edit written by a newer build. Losing it
// because this one could not parse it is the worst available failure
// for an authoritative store.
let dir = tempdir("refuse");
let image = dir.join("a.CR2");
std::fs::write(local_sidecar_path(&image), "drsc 99\n").unwrap();
assert!(save_local(&image, &Preset::capture(&edited()), Scope::Everything).is_err());
assert_eq!(
std::fs::read_to_string(local_sidecar_path(&image)).unwrap(),
"drsc 99\n",
"the file was modified despite the refusal"
);
}
#[test]
fn saving_leaves_no_temporary_file_behind() {
let dir = tempdir("no-temp");
save_local(
&dir.join("a.CR2"),
&Preset::capture(&edited()),
Scope::Everything,
)
.unwrap();
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n.ends_with(".tmp"))
.collect();
assert!(leftovers.is_empty(), "left {leftovers:?} behind");
}
// --- the clipboard ------------------------------------------------------
#[test]
fn a_fresh_clipboard_is_not_armed() {
assert!(!Clipboard::default().is_armed());
}
#[test]
fn the_scope_follows_the_setting_and_defaults_to_sparing_the_crop() {
let mut settings = dr_types::Settings::default();
assert_eq!(
scope_for(&settings),
Scope::Adjustments,
"a fresh install must not carry crops between photographs"
);
settings.develop.copy_includes_framing = true;
assert_eq!(scope_for(&settings), Scope::Everything);
}
#[test]
fn the_description_counts_operations_and_not_parameters() {
use dr_pipeline::ops::{exposure, white_balance};
let clipboard = Clipboard::default();
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
g.set_param(white_balance::ID, white_balance::TEMPERATURE, 20.0);
g.set_param(white_balance::ID, white_balance::TINT, 5.0);
clipboard.put(Preset::capture(&g));
// Three parameters, two operations.
assert_eq!(clipboard.describe(Scope::Adjustments), "2 adjustments");
}
#[test]
fn a_neutral_copy_is_armed_and_says_so() {
// Copying an unedited frame and pasting it clears the target, which is
// a real action — so the button must be live rather than looking like
// nothing was copied.
let clipboard = Clipboard::default();
clipboard.put(Preset::capture(&EditGraph::default_chain()));
assert!(clipboard.is_armed());
assert_eq!(clipboard.describe(Scope::Adjustments), "Neutral");
}
}
+15
View File
@@ -109,6 +109,7 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
window.set_settings_thumbnail_budget(budget::label(s.cache.thumbnail_budget_bytes).into());
window.set_settings_thumbnail_unlimited(s.cache.thumbnail_budget_bytes.is_none());
window.set_settings_keep_opened(s.cache.keep_opened_originals);
window.set_settings_copy_includes_framing(s.develop.copy_includes_framing);
window.set_settings_cache_usage(controller.usage_label.borrow().clone().into());
// --- export --------------------------------------------------------
@@ -430,6 +431,20 @@ where
});
}
// TRACES: FR-DEV-6
// Whether a copied edit carries the crop. Off by default — see
// `DevelopSettings::copy_includes_framing` for why that is the safe
// direction rather than merely the conservative one.
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_settings_copy_includes_framing_toggled(move |on| {
let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.develop.copy_includes_framing = on);
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = controller.clone();
+62
View File
@@ -566,6 +566,68 @@ export component GeometryPanel inherits Rectangle {
// and a control behind a lid is one the user does not know the pipeline has.
// The column is closed as a whole from the status strip instead, which is the
// control that was actually wanted.
// TRACES: FR-DEV-6
// Copying this photograph's settings, and pasting settings onto it.
//
// Buttons rather than a keyboard shortcut *alone*, because this has to work on
// a tablet where there is no modifier key to hold and no menu bar to hang the
// action from. The desktop shortcuts exist as well, wired in Rust; they are an
// accelerator for a control that is on screen either way, which is what keeps
// the feature discoverable on both platforms.
//
// The paste button carries what would be pasted rather than the bare word.
// "Paste" alone asks the user to remember what they copied and, crucially,
// whether the crop is coming with it — a question the label answers by
// naming the count the *current* scope would apply.
export component TransferPanel inherits VerticalLayout {
in property <bool> enabled: true;
/// Whether anything has been copied yet. Distinct from the clipboard
/// being *neutral*: a copy of an unedited frame is a real thing to paste,
/// since it clears the target.
in property <bool> armed: false;
/// What a paste would apply — "3 adjustments", or "Neutral".
in property <string> summary;
/// Whether the clipboard holds framing the current scope is dropping.
/// Only then is it worth saying anything about the crop.
in property <bool> framing-withheld: false;
callback copy();
callback paste();
padding: Theme.gap;
spacing: Theme.gap-sm;
HorizontalLayout {
PanelHeading { text: "SETTINGS"; }
Rectangle { horizontal-stretch: 1; }
}
HorizontalLayout {
spacing: Theme.gap-sm;
Button {
text: "Copy";
enabled: root.enabled;
horizontal-stretch: 1;
clicked => { root.copy(); }
}
Button {
text: "Paste";
// Enabled on `armed` rather than on the summary being non-empty,
// so pasting a neutral copy — which clears this image — stays
// available. Still needs an image to paste *onto*.
enabled: root.enabled && root.armed;
horizontal-stretch: 1;
clicked => { root.paste(); }
}
}
if root.armed: Caption {
text: root.summary + (root.framing-withheld ? " · crop not included" : "");
}
}
export component AdjustPanel inherits Rectangle {
in property <[ParamRow]> rows;
in property <bool> enabled: true;
+88 -2
View File
@@ -1,5 +1,5 @@
import { Theme } from "theme.slint";
import { AdjustPanel, GeometryPanel, ParamRow } from "adjust.slint";
import { AdjustPanel, GeometryPanel, ParamRow, TransferPanel } from "adjust.slint";
import { LaunchScreen } from "launch.slint";
import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
@@ -24,10 +24,20 @@ component StatusBar inherits Rectangle {
in property <bool> can-return-to-library: false;
/// Whether the develop column is currently shown, for the toggle's label.
in property <bool> panel-visible: true;
/// What the export button says. Rust owns it because the answer depends
/// on settings this component does not see — the format, and whether the
/// destination is this device or the server.
in property <string> export-label: "Export";
in property <bool> export-busy: false;
/// What the last export did. Sits beside the button rather than in a
/// dialogue: an export that succeeded needs no acknowledging, and one
/// that failed needs its reason where the retry is.
in property <string> export-status;
callback back-to-library();
callback open-settings();
callback toggle-panel();
callback export-image();
// 44px and `surface`, the same bar the library and settings draw.
//
@@ -88,6 +98,23 @@ component StatusBar inherits Rectangle {
clicked => { root.toggle-panel(); }
}
Caption {
text: root.export-status;
vertical-alignment: center;
overflow: elide;
}
// The export itself, beside the settings that shape it. This is the
// screen where a photograph is finished, so it is the screen where
// one is asked for — the grid can export a selection later, but a
// single finished frame is exported from in front of it.
Button {
text: root.export-busy ? "Exporting…" : root.export-label;
enabled: !root.export-busy;
y: (parent.height - self.height) / 2;
clicked => { root.export-image(); }
}
// Reachable from develop as well as from the grid: export defaults are
// most likely to be wanted with a finished photograph on screen, which
// is exactly where this bar is and the library header is not.
@@ -482,6 +509,22 @@ export component AppWindow inherits Window {
callback curve-reset(int);
callback reset-all();
// --- copying settings between photographs (FR-DEV-6) ---
//
// The clipboard is a window-lifetime thing rather than a view's, which is
// why it lives here and not inside the develop column: a copy is taken in
// develop and may be pasted onto a selection back in the library grid.
/// Whether anything has been copied this session.
in property <bool> settings-armed: false;
/// What a paste would apply, at the current scope — "3 adjustments".
in property <string> settings-summary;
/// Whether the copy holds framing that the current setting is dropping.
in property <bool> settings-framing-withheld: false;
callback copy-settings();
callback paste-settings();
/// Apply the clipboard to every selected image in the grid.
callback paste-settings-to-selection();
// --- settings (FR-EXP-1, FR-EXP-3, FR-NC-6a) ---
//
// A page rather than an overlay, and the outermost of the view conditions
@@ -498,6 +541,7 @@ export component AppWindow inherits Window {
in property <string> settings-thumbnail-budget: "";
in property <bool> settings-thumbnail-unlimited: false;
in property <bool> settings-keep-opened: true;
in property <bool> settings-copy-includes-framing: false;
in property <string> settings-cache-usage: "";
callback settings-original-budget-changed(string);
@@ -505,6 +549,7 @@ export component AppWindow inherits Window {
callback settings-thumbnail-budget-changed(string);
callback settings-thumbnail-unlimited-toggled(bool);
callback settings-keep-opened-toggled(bool);
callback settings-copy-includes-framing-toggled(bool);
in property <[string]> settings-format-labels;
in property <int> settings-format-selected: 0;
@@ -571,9 +616,18 @@ export component AppWindow inherits Window {
/// and one they opened on a narrow one stays open. Rust owns both for the
/// reason given above — a property read inside the layout and also feeding
/// it is a binding loop.
in property <bool> panel-visible: true;
in property <bool> panel-visible: true;
/// The export button's label and busy state, and the line it reports to.
/// Rust owns all three: what an export is called depends on the format and
/// on whether the destination is this device or the server, neither of
/// which the toolbar can see.
in property <string> export-label: "Export";
in property <bool> export-busy: false;
in property <string> export-status;
/// Show or hide the develop column.
callback toggle-panel();
/// Export the image on screen, using the settings as they stand.
callback export-image();
/// Show or hide the collections sidebar.
callback toggle-collections();
@@ -652,6 +706,7 @@ export component AppWindow inherits Window {
thumbnail-budget: root.settings-thumbnail-budget;
thumbnail-unlimited: root.settings-thumbnail-unlimited;
keep-opened: root.settings-keep-opened;
copy-includes-framing: root.settings-copy-includes-framing;
cache-usage: root.settings-cache-usage;
original-budget-changed(t) => { root.settings-original-budget-changed(t); }
@@ -663,6 +718,9 @@ export component AppWindow inherits Window {
root.settings-thumbnail-unlimited-toggled(on);
}
keep-opened-toggled(on) => { root.settings-keep-opened-toggled(on); }
copy-includes-framing-toggled(on) => {
root.settings-copy-includes-framing-toggled(on);
}
format-labels: root.settings-format-labels;
format-selected: root.settings-format-selected;
@@ -833,6 +891,11 @@ export component AppWindow inherits Window {
offset: root.library-offset;
selected-count: root.library-selected-count;
scope-label: root.collection-scope-label;
settings-armed: root.settings-armed;
settings-summary: root.settings-summary;
paste-settings-to-selection => {
root.paste-settings-to-selection();
}
sweep-done: root.library-sweep-done;
sweep-total: root.library-sweep-total;
@@ -929,9 +992,13 @@ export component AppWindow inherits Window {
// grid to return to.
can-return-to-library: root.library-total > 0;
panel-visible: root.panel-visible;
export-label: root.export-label;
export-busy: root.export-busy;
export-status: root.export-status;
back-to-library() => { root.back-to-library(); }
open-settings() => { root.settings-open(); }
toggle-panel() => { root.toggle-panel(); }
export-image() => { root.export-image(); }
}
HorizontalLayout {
@@ -1389,6 +1456,25 @@ export component AppWindow inherits Window {
background: Theme.rule;
}
// Between the framing and the colour work, because it
// acts on both: a paste is about the whole edit, and
// burying it under thirty sliders would put the one
// control that operates on all of them below all of
// them.
TransferPanel {
enabled: root.adjust-enabled;
armed: root.settings-armed;
summary: root.settings-summary;
framing-withheld: root.settings-framing-withheld;
copy => { root.copy-settings(); }
paste => { root.paste-settings(); }
}
Rectangle {
height: 1px;
background: Theme.rule;
}
AdjustPanel {
vertical-stretch: 1;
rows: root.adjust-rows;
+34
View File
@@ -452,11 +452,16 @@ component HeaderActions inherits HorizontalLayout {
in property <bool> scanning: false;
in property <bool> syncing: false;
in property <bool> scope-pinned: false;
/// TRACES: FR-DEV-6
/// Whether settings have been copied, and what pasting them would apply.
in property <bool> settings-armed: false;
in property <string> settings-summary;
/// Centres each button in a 44px header. Off in the disclosure row, which
/// is sized to its content.
in property <bool> centred: true;
in property <length> row-height: 44px;
callback paste-settings-to-selection();
callback remove-from-collection();
callback change-library();
callback toggle-pin-scope();
@@ -466,6 +471,21 @@ component HeaderActions inherits HorizontalLayout {
spacing: Theme.gap;
// TRACES: FR-DEV-6
// Batch-apply the copied settings. Shown only with both a selection and a
// clipboard, because it is meaningless without either — and because a
// permanently visible button that is usually disabled teaches the user to
// stop reading this row.
//
// The count is in the label rather than in a confirmation: this writes to
// every selected image, and "Paste to 40" said before the click is worth
// more than a dialogue asking the same question after it.
if root.selected-count > 0 && root.settings-armed: Button {
text: "Paste to " + root.selected-count;
y: root.centred ? (root.row-height - self.height) / 2 : 0;
clicked => { root.paste-settings-to-selection(); }
}
// Removing from a collection is only meaningful while the grid is scoped
// to one. Offering it unscoped would invite the reading "remove from the
// library", which nothing here does.
@@ -725,6 +745,14 @@ export component LibraryGrid inherits Rectangle {
/// How many images are selected, for the header's count.
in property <int> selected-count: 0;
// TRACES: FR-DEV-6
// Batch-applying copied develop settings to the selection. The clipboard
// itself belongs to the window — a copy is taken in the develop view and
// pasted here — so the grid only reports what it has and asks.
in property <bool> settings-armed: false;
in property <string> settings-summary;
callback paste-settings-to-selection();
// --- the keyboard cursor ------------------------------------------------
//
// Where the keyboard is in the library, as an **image ordinal** — not a
@@ -861,6 +889,9 @@ export component LibraryGrid inherits Rectangle {
scanning: root.scanning;
syncing: root.syncing;
scope-pinned: root.scope-pinned;
settings-armed: root.settings-armed;
settings-summary: root.settings-summary;
paste-settings-to-selection => { root.paste-settings-to-selection(); }
remove-from-collection => { root.remove-from-collection(); }
change-library => { root.change-library(); }
toggle-pin-scope => { root.toggle-pin-scope(); }
@@ -905,6 +936,9 @@ export component LibraryGrid inherits Rectangle {
scanning: root.scanning;
syncing: root.syncing;
scope-pinned: root.scope-pinned;
settings-armed: root.settings-armed;
settings-summary: root.settings-summary;
paste-settings-to-selection => { root.paste-settings-to-selection(); }
remove-from-collection => { root.remove-from-collection(); }
change-library => { root.change-library(); }
toggle-pin-scope => { root.toggle-pin-scope(); }
+33
View File
@@ -76,6 +76,9 @@ export component SettingsPage inherits Rectangle {
in-out property <string> thumbnail-budget;
in property <bool> thumbnail-unlimited: false;
in property <bool> keep-opened: true;
/// TRACES: FR-DEV-6
/// Whether copying settings carries the crop and rotation with it.
in property <bool> copy-includes-framing: false;
/// What the cache currently holds. Empty hides the line.
in property <string> cache-usage;
@@ -84,6 +87,7 @@ export component SettingsPage inherits Rectangle {
callback thumbnail-budget-changed(string);
callback thumbnail-unlimited-toggled(bool);
callback keep-opened-toggled(bool);
callback copy-includes-framing-toggled(bool);
// --- export --------------------------------------------------------
in property <[string]> format-labels;
@@ -359,6 +363,35 @@ export component SettingsPage inherits Rectangle {
}
}
// --- develop (FR-DEV-6) ----------------------------------
Rectangle {
width: content.column;
height: develop-panel.preferred-height;
develop-panel := Panel {
width: 100%;
spacing: Theme.gap;
PanelHeading { text: "DEVELOP"; }
Check {
label: "Copy crop and rotation with settings";
// Says what the *off* state does, because off is
// the default and is the behaviour that needs
// explaining: a user who has never opened this
// page should still be able to predict what a
// paste did to their crops.
hint: "Off, pasting settings changes only colour and "
+ "tone, and each photograph keeps its own "
+ "composition. On, the crop, straightening, "
+ "rotation and flips travel too — which "
+ "re-frames every image pasted onto.";
checked: root.copy-includes-framing;
toggled(on) => { root.copy-includes-framing-toggled(on); }
}
}
}
// --- export ----------------------------------------------
Rectangle {
width: content.column;