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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user