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:
Generated
+1
@@ -1472,6 +1472,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"dr-catalog",
|
"dr-catalog",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
|
"dr-export",
|
||||||
"dr-gpu",
|
"dr-gpu",
|
||||||
"dr-pipeline",
|
"dr-pipeline",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ pub mod graph;
|
|||||||
pub mod lens;
|
pub mod lens;
|
||||||
pub mod operation;
|
pub mod operation;
|
||||||
pub mod ops;
|
pub mod ops;
|
||||||
|
pub mod preset;
|
||||||
pub mod sidecar;
|
pub mod sidecar;
|
||||||
|
|
||||||
pub use descriptor::{
|
pub use descriptor::{
|
||||||
@@ -50,6 +51,7 @@ pub use operation::{
|
|||||||
compose, compose_with_framing, Affects, ComposedShader, Helper, Operation, Uniform,
|
compose, compose_with_framing, Affects, ComposedShader, Helper, Operation, Uniform,
|
||||||
RESERVED_UNIFORM_FIELDS,
|
RESERVED_UNIFORM_FIELDS,
|
||||||
};
|
};
|
||||||
|
pub use preset::{Preset, Scope};
|
||||||
pub use sidecar::{Sidecar, Version};
|
pub use sidecar::{Sidecar, Version};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
//! TRACES: FR-DEV-6
|
||||||
|
//! An edit lifted off one photograph and dropped onto another.
|
||||||
|
//!
|
||||||
|
//! # This is the same data a sidecar already stores
|
||||||
|
//!
|
||||||
|
//! A [`Preset`] is a map of `(op, param) -> value` holding only what differs
|
||||||
|
//! from default — which is precisely [`Version::params`](crate::Version). That
|
||||||
|
//! is not a coincidence to be tidied away later: copying settings between two
|
||||||
|
//! images and persisting one image's settings are the same operation seen from
|
||||||
|
//! two ends, so they share one representation and one capture routine
|
||||||
|
//! ([`Preset::capture`], which `sidecar` calls). A second, parallel notion of
|
||||||
|
//! "a bundle of parameter values" would be a second thing to keep in step with
|
||||||
|
//! the descriptors.
|
||||||
|
//!
|
||||||
|
//! It follows that this module names no operation either, with the single
|
||||||
|
//! exception of framing — for the reason below, which is a statement about
|
||||||
|
//! photographs rather than about code.
|
||||||
|
//!
|
||||||
|
//! # Why framing is its own scope
|
||||||
|
//!
|
||||||
|
//! A crop is a decision about *this* photograph's composition. Copying colour
|
||||||
|
//! from one frame to the next is what a photographer means by "make these
|
||||||
|
//! match"; copying the crop as well re-frames every one of them to a rectangle
|
||||||
|
//! chosen while looking at a different picture, and on a batch of forty that is
|
||||||
|
//! forty compositions destroyed by one action.
|
||||||
|
//!
|
||||||
|
//! So the default [`Scope::Adjustments`] leaves the target's framing where it
|
||||||
|
//! is, and [`Scope::Everything`] is available for the case the exclusion exists
|
||||||
|
//! to protect against being impossible otherwise — applying one aspect ratio
|
||||||
|
//! across a shoot. The choice is the caller's; neither is hardcoded here.
|
||||||
|
//!
|
||||||
|
//! # Why applying replaces rather than overlays
|
||||||
|
//!
|
||||||
|
//! Within its scope, [`Preset::apply`] resets first. A parameter absent from
|
||||||
|
//! the preset means *default*, exactly as absence means default in a sidecar
|
||||||
|
//! — so pasting a neutral edit clears the target rather than leaving the
|
||||||
|
//! target's own exposure standing underneath. Overlaying would make the result
|
||||||
|
//! depend on what the target happened to hold, and "these two images now match"
|
||||||
|
//! is the whole claim the action makes.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::descriptor::{OpId, ParamId};
|
||||||
|
use crate::graph::EditGraph;
|
||||||
|
|
||||||
|
/// Which part of an edit a copy carries.
|
||||||
|
///
|
||||||
|
/// Two variants rather than a per-operation mask because the distinction being
|
||||||
|
/// drawn is not "which operations" but "is this about the picture's colour or
|
||||||
|
/// about its shape". Everything in the chain but framing answers the first.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum Scope {
|
||||||
|
/// Colour and tone. The target keeps its own crop, straightening,
|
||||||
|
/// rotation and flips.
|
||||||
|
#[default]
|
||||||
|
Adjustments,
|
||||||
|
/// The whole edit, framing included.
|
||||||
|
Everything,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scope {
|
||||||
|
/// Whether this scope reaches the named operation.
|
||||||
|
///
|
||||||
|
/// Takes a `&str` rather than an [`OpId`] because the caller may be
|
||||||
|
/// holding a name read from a file, which has no `'static` lifetime to
|
||||||
|
/// offer — the sidecar path amends a parameter map without ever building
|
||||||
|
/// a graph.
|
||||||
|
pub fn covers(self, op: &str) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::Everything => true,
|
||||||
|
Self::Adjustments => op != crate::framing::ID.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A set of non-default parameter values, ready to apply elsewhere.
|
||||||
|
///
|
||||||
|
/// Ordered, so two captures of the same edit compare equal and a caller can
|
||||||
|
/// tell whether the clipboard actually changed.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
|
pub struct Preset {
|
||||||
|
params: BTreeMap<(String, String), f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Preset {
|
||||||
|
/// Every non-default parameter in `graph`.
|
||||||
|
///
|
||||||
|
/// Walks [`EditGraph::capabilities`] — the same list the panel builds
|
||||||
|
/// controls from and the sidecar persists — so an operation is copyable by
|
||||||
|
/// virtue of being in the chain, with nothing to register (FR-DEV-3c).
|
||||||
|
///
|
||||||
|
/// Captured at full [`Scope::Everything`]: filtering happens when the
|
||||||
|
/// preset is *applied*, not when it is taken. Otherwise the clipboard would
|
||||||
|
/// have to be re-copied to change one's mind about framing, and a preset
|
||||||
|
/// that had already discarded the crop could never grow it back.
|
||||||
|
pub fn capture(graph: &EditGraph) -> Self {
|
||||||
|
let mut params = BTreeMap::new();
|
||||||
|
for cap in graph.capabilities() {
|
||||||
|
for p in &cap.params {
|
||||||
|
if p.is_modified() {
|
||||||
|
params.insert((cap.id.0.to_string(), p.id.0.to_string()), p.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self { params }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build from an already-captured parameter map — a sidecar's, typically.
|
||||||
|
pub fn from_params(params: BTreeMap<(String, String), f32>) -> Self {
|
||||||
|
Self { params }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parameters, for a caller that stores them.
|
||||||
|
pub fn params(&self) -> &BTreeMap<(String, String), f32> {
|
||||||
|
&self.params
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume into the parameter map.
|
||||||
|
pub fn into_params(self) -> BTreeMap<(String, String), f32> {
|
||||||
|
self.params
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this preset carries anything at all.
|
||||||
|
///
|
||||||
|
/// An empty preset is a *neutral* edit rather than a missing one, and
|
||||||
|
/// applying it is meaningful: it returns the target to default. What this
|
||||||
|
/// answers is whether there is a clipboard to offer, which is why the UI
|
||||||
|
/// asks it before enabling a paste.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.params.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many parameters were captured.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.params.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this preset carries any framing — a crop, a rotation, a flip or
|
||||||
|
/// a straightening angle.
|
||||||
|
///
|
||||||
|
/// What the interface asks to decide whether offering "include crop and
|
||||||
|
/// rotation" would change anything for *this* clipboard. Offering it on a
|
||||||
|
/// copy that has no framing in it promises an effect that cannot happen.
|
||||||
|
pub fn touches_framing(&self) -> bool {
|
||||||
|
self.params
|
||||||
|
.keys()
|
||||||
|
.any(|(op, _)| !Scope::Adjustments.covers(op))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many operations this preset touches, in scope.
|
||||||
|
///
|
||||||
|
/// For the interface's "3 adjustments" readout. Counted over operations
|
||||||
|
/// rather than parameters because thirty-six mixer sliders is a number
|
||||||
|
/// about the mixer's shape, not about how much was copied.
|
||||||
|
pub fn op_count(&self, scope: Scope) -> usize {
|
||||||
|
let mut ops: Vec<&str> = self
|
||||||
|
.params
|
||||||
|
.keys()
|
||||||
|
.map(|(op, _)| op.as_str())
|
||||||
|
.filter(|op| scope.covers(op))
|
||||||
|
.collect();
|
||||||
|
ops.sort_unstable();
|
||||||
|
ops.dedup();
|
||||||
|
ops.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply to a graph, replacing whatever it held within `scope`.
|
||||||
|
///
|
||||||
|
/// Parameters this build does not recognise are skipped with a warning by
|
||||||
|
/// the same route a sidecar's are — a preset may have been captured by a
|
||||||
|
/// newer build, and an unknown name must cost its own line rather than the
|
||||||
|
/// paste.
|
||||||
|
///
|
||||||
|
/// The **view is preserved**. Zoom and pan say where the user is looking,
|
||||||
|
/// not what the photograph is; resetting the framing would throw them back
|
||||||
|
/// to a fitted view mid-comparison, which reads as the paste having
|
||||||
|
/// navigated somewhere. This mirrors `DevelopSession::reset_framing`, and
|
||||||
|
/// lives here so every caller inherits it rather than each remembering.
|
||||||
|
pub fn apply(&self, graph: &mut EditGraph, scope: Scope) {
|
||||||
|
let view = graph.framing().view();
|
||||||
|
|
||||||
|
// Clear the scope first, so absence means default (see the module
|
||||||
|
// note). Collected before writing because `capabilities` borrows the
|
||||||
|
// graph and `set_param` needs it mutably.
|
||||||
|
let clears: Vec<(OpId, ParamId, f32)> = graph
|
||||||
|
.capabilities()
|
||||||
|
.iter()
|
||||||
|
.filter(|cap| scope.covers(cap.id.0))
|
||||||
|
.flat_map(|cap| cap.params.iter().map(|p| (cap.id, p.id, p.default)))
|
||||||
|
.collect();
|
||||||
|
for (op, param, default) in clears {
|
||||||
|
graph.set_param(op, param, default);
|
||||||
|
}
|
||||||
|
|
||||||
|
for ((op, param), value) in &self.params {
|
||||||
|
if !scope.covers(op) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((op, param)) = resolve(graph, op, param) else {
|
||||||
|
log::warn!("preset: unknown parameter {op}.{param}; ignoring");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
graph.set_param(op, param, *value);
|
||||||
|
}
|
||||||
|
|
||||||
|
graph.framing_mut().set_view(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply to a parameter map — the sidecar of an image that is not open.
|
||||||
|
///
|
||||||
|
/// The batch path. Applying to forty images by loading forty edit graphs
|
||||||
|
/// would mean instantiating the whole chain forty times to move some
|
||||||
|
/// numbers between two maps; the graph adds nothing here because there is
|
||||||
|
/// no rendering to do and clamping happens when the file is next read into
|
||||||
|
/// one ([`EditGraph::set_param`] clamps, and `Version::apply` goes through
|
||||||
|
/// it).
|
||||||
|
///
|
||||||
|
/// Same replacement rule as [`Self::apply`]: the target's in-scope keys go,
|
||||||
|
/// the preset's arrive, and out-of-scope keys — the target's own crop, on
|
||||||
|
/// the default scope — are left exactly as they were.
|
||||||
|
pub fn amend(&self, target: &mut BTreeMap<(String, String), f32>, scope: Scope) {
|
||||||
|
target.retain(|(op, _), _| !scope.covers(op));
|
||||||
|
for ((op, param), value) in &self.params {
|
||||||
|
if scope.covers(op) {
|
||||||
|
target.insert((op.clone(), param.clone()), *value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the `'static` ids matching these names, or `None` if this build has no
|
||||||
|
/// such parameter.
|
||||||
|
///
|
||||||
|
/// Looking them up in the descriptors rather than leaking the caller's strings
|
||||||
|
/// is what bounds memory: a name read from a file or carried on a clipboard
|
||||||
|
/// never becomes a `'static`.
|
||||||
|
pub(crate) fn resolve(graph: &EditGraph, op: &str, param: &str) -> Option<(OpId, ParamId)> {
|
||||||
|
let cap = graph.capabilities().into_iter().find(|c| c.id.0 == op)?;
|
||||||
|
let p = cap.params.iter().find(|p| p.id.0 == param)?;
|
||||||
|
Some((cap.id, p.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::framing;
|
||||||
|
use crate::ops::{exposure, saturation, white_balance};
|
||||||
|
use crate::CropRect;
|
||||||
|
|
||||||
|
/// A graph with colour *and* framing moved off default, which is what makes
|
||||||
|
/// the scope distinction observable.
|
||||||
|
fn edited() -> EditGraph {
|
||||||
|
let mut g = EditGraph::default_chain();
|
||||||
|
g.set_param(exposure::ID, exposure::EXPOSURE, 0.75);
|
||||||
|
g.set_param(white_balance::ID, white_balance::TEMPERATURE, 30.0);
|
||||||
|
g.set_crop(CropRect {
|
||||||
|
x: 0.1,
|
||||||
|
y: 0.1,
|
||||||
|
width: 0.5,
|
||||||
|
height: 0.5,
|
||||||
|
});
|
||||||
|
g.set_param(framing::ID, framing::ANGLE, -2.0);
|
||||||
|
g
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_copy_carries_only_what_was_changed() {
|
||||||
|
// The property the whole format rests on, restated for the clipboard:
|
||||||
|
// a neutral operation contributes nothing, so pasting cannot carry a
|
||||||
|
// value the source never set.
|
||||||
|
let preset = Preset::capture(&edited());
|
||||||
|
assert!(preset
|
||||||
|
.params()
|
||||||
|
.contains_key(&("exposure".into(), "exposure".into())));
|
||||||
|
assert!(
|
||||||
|
!preset.params().keys().any(|(op, _)| op == saturation::ID.0),
|
||||||
|
"an untouched operation must not be copied"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_neutral_graph_copies_nothing() {
|
||||||
|
assert!(Preset::capture(&EditGraph::default_chain()).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_copy_captures_framing_even_though_the_default_scope_drops_it() {
|
||||||
|
// Capture is deliberately unfiltered: the decision about framing is
|
||||||
|
// made at paste time, so a user who ticks "include crop" after copying
|
||||||
|
// must not have to copy again.
|
||||||
|
let preset = Preset::capture(&edited());
|
||||||
|
assert!(preset.touches_framing());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pasting_adjustments_leaves_the_targets_composition_alone() {
|
||||||
|
// The case the default scope exists for: two photographs framed
|
||||||
|
// differently, made to match in colour without either being re-cropped.
|
||||||
|
let source = edited();
|
||||||
|
let preset = Preset::capture(&source);
|
||||||
|
|
||||||
|
let mut target = EditGraph::default_chain();
|
||||||
|
let target_crop = CropRect {
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.25,
|
||||||
|
width: 1.0,
|
||||||
|
height: 0.5,
|
||||||
|
};
|
||||||
|
target.set_crop(target_crop);
|
||||||
|
|
||||||
|
preset.apply(&mut target, Scope::Adjustments);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
target.param(exposure::ID, exposure::EXPOSURE),
|
||||||
|
Some(0.75),
|
||||||
|
"the colour must arrive"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(target.crop().width - target_crop.width).abs() < 1e-5
|
||||||
|
&& (target.crop().y - target_crop.y).abs() < 1e-5,
|
||||||
|
"the target's own crop must survive: {:?}",
|
||||||
|
target.crop()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target.param(framing::ID, framing::ANGLE),
|
||||||
|
Some(0.0),
|
||||||
|
"the source's straightening must not travel on this scope"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pasting_everything_carries_the_composition_too() {
|
||||||
|
let preset = Preset::capture(&edited());
|
||||||
|
let mut target = EditGraph::default_chain();
|
||||||
|
preset.apply(&mut target, Scope::Everything);
|
||||||
|
|
||||||
|
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(-2.0));
|
||||||
|
assert!(
|
||||||
|
(target.crop().width - 0.5).abs() < 1e-5,
|
||||||
|
"{:?}",
|
||||||
|
target.crop()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pasting_replaces_rather_than_overlaying() {
|
||||||
|
// Pasting a neutral copy must *clear* the target. Were this an
|
||||||
|
// overlay, "make these match" would leave whatever the target already
|
||||||
|
// had underneath, and the two images would not in fact match.
|
||||||
|
let neutral = Preset::capture(&EditGraph::default_chain());
|
||||||
|
|
||||||
|
let mut target = EditGraph::default_chain();
|
||||||
|
target.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
|
||||||
|
target.set_param(saturation::ID, saturation::SATURATION, -50.0);
|
||||||
|
|
||||||
|
neutral.apply(&mut target, Scope::Adjustments);
|
||||||
|
|
||||||
|
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
|
||||||
|
assert_eq!(
|
||||||
|
target.param(saturation::ID, saturation::SATURATION),
|
||||||
|
Some(0.0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_paste_that_excludes_framing_does_not_clear_the_targets_framing() {
|
||||||
|
// The other half of the replacement rule: "replace" is scoped. A
|
||||||
|
// neutral paste must not straighten the target back to zero, or
|
||||||
|
// excluding framing would still destroy it — just to a different value.
|
||||||
|
let neutral = Preset::capture(&EditGraph::default_chain());
|
||||||
|
|
||||||
|
let mut target = EditGraph::default_chain();
|
||||||
|
target.set_param(framing::ID, framing::ANGLE, 3.5);
|
||||||
|
|
||||||
|
neutral.apply(&mut target, Scope::Adjustments);
|
||||||
|
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(3.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pasting_leaves_the_view_where_the_user_was_looking() {
|
||||||
|
// Zoom is navigation, not an edit. A paste that reset it would throw
|
||||||
|
// the user out of a 4x inspection they were making the comparison at.
|
||||||
|
let preset = Preset::capture(&edited());
|
||||||
|
|
||||||
|
let mut target = EditGraph::default_chain();
|
||||||
|
target.framing_mut().set_view(CropRect {
|
||||||
|
x: 0.25,
|
||||||
|
y: 0.25,
|
||||||
|
width: 0.25,
|
||||||
|
height: 0.25,
|
||||||
|
});
|
||||||
|
|
||||||
|
preset.apply(&mut target, Scope::Everything);
|
||||||
|
assert!(
|
||||||
|
target.framing().is_zoomed(),
|
||||||
|
"the paste threw away the viewport: {:?}",
|
||||||
|
target.framing().view()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_paste_does_not_disturb_how_the_file_stored_its_pixels() {
|
||||||
|
// Orientation is a fact about the target's own file. A preset copied
|
||||||
|
// from a landscape frame must not lay that frame's sensor scan over a
|
||||||
|
// portrait one — the image would open on its side.
|
||||||
|
let preset = Preset::capture(&edited());
|
||||||
|
|
||||||
|
let mut sideways = EditGraph::default_chain();
|
||||||
|
sideways.set_orientation(dr_types::Orientation::from_exif(6));
|
||||||
|
preset.apply(&mut sideways, Scope::Everything);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sideways.framing().baseline(),
|
||||||
|
dr_types::Orientation::from_exif(6)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_parameter_costs_its_own_line_and_not_the_paste() {
|
||||||
|
// A clipboard captured by a newer build. The rest must still apply, or
|
||||||
|
// one unfamiliar operation would silently discard the whole copy.
|
||||||
|
let mut params = BTreeMap::new();
|
||||||
|
params.insert(("time_machine".to_string(), "year".to_string()), 1994.0);
|
||||||
|
params.insert(("exposure".to_string(), "exposure".to_string()), 1.25);
|
||||||
|
|
||||||
|
let mut target = EditGraph::default_chain();
|
||||||
|
Preset::from_params(params).apply(&mut target, Scope::Adjustments);
|
||||||
|
|
||||||
|
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(1.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_out_of_range_value_is_clamped_rather_than_trusted() {
|
||||||
|
let mut params = BTreeMap::new();
|
||||||
|
params.insert(("exposure".to_string(), "exposure".to_string()), 99.0);
|
||||||
|
|
||||||
|
let mut target = EditGraph::default_chain();
|
||||||
|
Preset::from_params(params).apply(&mut target, Scope::Adjustments);
|
||||||
|
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(5.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the batch path, which has no graph ---------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn amending_a_map_replaces_the_scope_and_spares_the_rest() {
|
||||||
|
let preset = Preset::capture(&edited());
|
||||||
|
|
||||||
|
// A target that has its own crop and its own, different, exposure.
|
||||||
|
let mut target = BTreeMap::new();
|
||||||
|
target.insert(("exposure".to_string(), "exposure".to_string()), -1.0);
|
||||||
|
target.insert(("saturation".to_string(), "saturation".to_string()), 40.0);
|
||||||
|
target.insert(("framing".to_string(), "crop_w".to_string()), 0.3);
|
||||||
|
|
||||||
|
preset.amend(&mut target, Scope::Adjustments);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
target.get(&("exposure".to_string(), "exposure".to_string())),
|
||||||
|
Some(&0.75),
|
||||||
|
"the copied value must land"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!target.contains_key(&("saturation".to_string(), "saturation".to_string())),
|
||||||
|
"an in-scope key the preset does not set must be cleared, not kept"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target.get(&("framing".to_string(), "crop_w".to_string())),
|
||||||
|
Some(&0.3),
|
||||||
|
"the target's own crop must survive an adjustments-only paste"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn amending_at_full_scope_replaces_the_targets_crop_too() {
|
||||||
|
let preset = Preset::capture(&edited());
|
||||||
|
let mut target = BTreeMap::new();
|
||||||
|
target.insert(("framing".to_string(), "crop_w".to_string()), 0.3);
|
||||||
|
|
||||||
|
preset.amend(&mut target, Scope::Everything);
|
||||||
|
assert_eq!(
|
||||||
|
target.get(&("framing".to_string(), "crop_w".to_string())),
|
||||||
|
Some(&0.5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_map_path_and_the_graph_path_agree() {
|
||||||
|
// Two routes to the same result — one through a graph, one through a
|
||||||
|
// bare map — and a batch apply must not produce a different edit from
|
||||||
|
// pasting onto the image with it open. Asserted by round-tripping the
|
||||||
|
// amended map back through a graph and comparing every parameter.
|
||||||
|
let preset = Preset::capture(&edited());
|
||||||
|
|
||||||
|
for scope in [Scope::Adjustments, Scope::Everything] {
|
||||||
|
let mut target_graph = EditGraph::default_chain();
|
||||||
|
target_graph.set_param(saturation::ID, saturation::SATURATION, 20.0);
|
||||||
|
target_graph.set_param(framing::ID, framing::ANGLE, 4.0);
|
||||||
|
|
||||||
|
let mut target_map = Preset::capture(&target_graph).into_params();
|
||||||
|
|
||||||
|
preset.apply(&mut target_graph, scope);
|
||||||
|
preset.amend(&mut target_map, scope);
|
||||||
|
|
||||||
|
let mut rebuilt = EditGraph::default_chain();
|
||||||
|
Preset::from_params(target_map).apply(&mut rebuilt, Scope::Everything);
|
||||||
|
|
||||||
|
for cap in target_graph.capabilities() {
|
||||||
|
for p in &cap.params {
|
||||||
|
assert_eq!(
|
||||||
|
rebuilt.param(cap.id, p.id),
|
||||||
|
Some(p.value),
|
||||||
|
"{scope:?}: {}.{} differs between the graph and map paths",
|
||||||
|
cap.id,
|
||||||
|
p.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_copy_round_trips_through_a_paste() {
|
||||||
|
// The end-to-end claim the feature makes: after pasting, the target
|
||||||
|
// holds the source's edit. Checked over the whole chain rather than a
|
||||||
|
// sample, so an operation that needed special handling would fail here.
|
||||||
|
let source = edited();
|
||||||
|
let preset = Preset::capture(&source);
|
||||||
|
|
||||||
|
let mut target = EditGraph::default_chain();
|
||||||
|
preset.apply(&mut target, Scope::Everything);
|
||||||
|
|
||||||
|
for cap in source.capabilities() {
|
||||||
|
for p in &cap.params {
|
||||||
|
assert_eq!(
|
||||||
|
target.param(cap.id, p.id),
|
||||||
|
Some(p.value),
|
||||||
|
"{}.{} did not survive the copy",
|
||||||
|
cap.id,
|
||||||
|
p.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn operations_are_counted_in_scope() {
|
||||||
|
let preset = Preset::capture(&edited());
|
||||||
|
// exposure, white_balance and framing were touched.
|
||||||
|
assert_eq!(preset.op_count(Scope::Everything), 3);
|
||||||
|
assert_eq!(preset.op_count(Scope::Adjustments), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_names_framing_and_nothing_else() {
|
||||||
|
// The one operation this module knows by name. If a second ever
|
||||||
|
// appears here, it should be because someone decided it is about the
|
||||||
|
// picture's shape rather than because it was convenient.
|
||||||
|
let g = EditGraph::default_chain();
|
||||||
|
let excluded: Vec<&str> = g
|
||||||
|
.capabilities()
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.id.0)
|
||||||
|
.filter(|id| !Scope::Adjustments.covers(id))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(excluded, vec![framing::ID.0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,8 +66,8 @@ use std::collections::BTreeMap;
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
use crate::descriptor::{OpId, ParamId};
|
|
||||||
use crate::graph::EditGraph;
|
use crate::graph::EditGraph;
|
||||||
|
use crate::preset::{resolve, Preset};
|
||||||
|
|
||||||
/// Format version of the document itself.
|
/// Format version of the document itself.
|
||||||
///
|
///
|
||||||
@@ -348,27 +348,13 @@ fn merge_judgement(ours: u8, theirs: u8, remote_wins: bool) -> u8 {
|
|||||||
/// Reads [`EditGraph::capabilities`] — the same list the UI builds controls
|
/// Reads [`EditGraph::capabilities`] — the same list the UI builds controls
|
||||||
/// from — so an operation is persisted by virtue of being in the chain, with
|
/// from — so an operation is persisted by virtue of being in the chain, with
|
||||||
/// nothing to register and nothing to forget.
|
/// nothing to register and nothing to forget.
|
||||||
fn capture(graph: &EditGraph) -> BTreeMap<(String, String), f32> {
|
|
||||||
let mut out = BTreeMap::new();
|
|
||||||
for cap in graph.capabilities() {
|
|
||||||
for p in &cap.params {
|
|
||||||
if p.is_modified() {
|
|
||||||
out.insert((cap.id.0.to_string(), p.id.0.to_string()), p.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find the `'static` ids matching these names, or `None` if this build has
|
|
||||||
/// no such parameter.
|
|
||||||
///
|
///
|
||||||
/// Looking them up in the descriptors rather than leaking the file's strings
|
/// Delegated to [`Preset::capture`] rather than reimplemented: a version's
|
||||||
/// is what bounds memory: an unrecognised name never becomes a `'static`.
|
/// parameters and a copied preset are the same values taken from the same
|
||||||
fn resolve(graph: &EditGraph, op: &str, param: &str) -> Option<(OpId, ParamId)> {
|
/// list, and two routines building the same map would be two places for the
|
||||||
let cap = graph.capabilities().into_iter().find(|c| c.id.0 == op)?;
|
/// non-default rule to drift.
|
||||||
let p = cap.params.iter().find(|p| p.id.0 == param)?;
|
fn capture(graph: &EditGraph) -> BTreeMap<(String, String), f32> {
|
||||||
Some((cap.id, p.id))
|
Preset::capture(graph).into_params()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Sidecar {
|
impl Sidecar {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ pub mod settings;
|
|||||||
|
|
||||||
pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier};
|
pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier};
|
||||||
pub use settings::{
|
pub use settings::{
|
||||||
CacheSettings, CollisionPolicy, ColourSpace, ExportFormat, ExportSettings, ExportTarget,
|
CacheSettings, CollisionPolicy, ColourSpace, DevelopSettings, ExportFormat, ExportSettings,
|
||||||
OutputSharpening, Settings, SizingMode,
|
ExportTarget, OutputSharpening, Settings, SizingMode,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Identifies a granted library location — a directory on Linux, a persisted
|
/// Identifies a granted library location — a directory on Linux, a persisted
|
||||||
|
|||||||
@@ -51,6 +51,38 @@ use serde::{Deserialize, Serialize};
|
|||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub cache: CacheSettings,
|
pub cache: CacheSettings,
|
||||||
pub export: ExportSettings,
|
pub export: ExportSettings,
|
||||||
|
pub develop: DevelopSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Develop
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
/// How the develop tools behave, where the answer is a matter of taste rather
|
||||||
|
/// than of correctness.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct DevelopSettings {
|
||||||
|
/// Whether copying settings carries crop, straightening, rotation and
|
||||||
|
/// flips onto the images they are pasted to.
|
||||||
|
///
|
||||||
|
/// **Off by default, deliberately.** A crop is a decision about one
|
||||||
|
/// photograph's composition; carrying it to the next re-frames a picture
|
||||||
|
/// that was composed differently, and applied across a selection of forty
|
||||||
|
/// it destroys forty compositions in one action that looks like a
|
||||||
|
/// colour-matching step. Colour is what "make these match" almost always
|
||||||
|
/// means.
|
||||||
|
///
|
||||||
|
/// It is a setting rather than a fixed rule because the excluded case is
|
||||||
|
/// real and otherwise unreachable: applying one aspect ratio across a
|
||||||
|
/// shoot is a legitimate thing to want, and there would be no way to ask
|
||||||
|
/// for it.
|
||||||
|
///
|
||||||
|
/// A device preference rather than a per-paste prompt — a photographer who
|
||||||
|
/// works one way works that way every time, and a dialogue on every paste
|
||||||
|
/// would be a question with the same answer forty times over.
|
||||||
|
pub copy_includes_framing: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+29
-29
@@ -9,17 +9,17 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
|
|||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Source files scanned | 109 |
|
| Source files scanned | 113 |
|
||||||
| TRACES tags found | 154 |
|
| TRACES tags found | 186 |
|
||||||
| Requirements defined | 151 |
|
| Requirements defined | 151 |
|
||||||
| Requirements covered | 73 |
|
| Requirements covered | 77 |
|
||||||
| **Coverage** | **48.3%** (73/151) |
|
| **Coverage** | **51.0%** (77/151) |
|
||||||
|
|
||||||
### By type
|
### By type
|
||||||
|
|
||||||
| Type | Covered | Defined |
|
| Type | Covered | Defined |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| FR | 57 | 97 |
|
| FR | 61 | 97 |
|
||||||
| NFR | 14 | 48 |
|
| NFR | 14 | 48 |
|
||||||
| R | 2 | 6 |
|
| R | 2 | 6 |
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ _None._
|
|||||||
| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:185`](../core/dr-types/src/lib.rs#L185), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479), [`tools/traceability/src/lib.rs:511`](../tools/traceability/src/lib.rs#L511), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) |
|
| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:185`](../core/dr-types/src/lib.rs#L185), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479), [`tools/traceability/src/lib.rs:511`](../tools/traceability/src/lib.rs#L511), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) |
|
||||||
| FR-CAT-11 | [`ui/dr-ui/src/library.rs:150`](../ui/dr-ui/src/library.rs#L150) |
|
| FR-CAT-11 | [`ui/dr-ui/src/library.rs:150`](../ui/dr-ui/src/library.rs#L150) |
|
||||||
| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:108`](../core/dr-pipeline/src/sidecar.rs#L108) |
|
| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:108`](../core/dr-pipeline/src/sidecar.rs#L108) |
|
||||||
| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:258`](../core/dr-catalog/src/schema.rs#L258), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:366`](../core/dr-sync-nextcloud/src/lib.rs#L366), [`core/dr-sync/src/lib.rs:122`](../core/dr-sync/src/lib.rs#L122), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1299`](../ui/dr-ui/src/collections_ui.rs#L1299), [`ui/dr-ui/src/collections_ui.rs:817`](../ui/dr-ui/src/collections_ui.rs#L817), [`ui/dr-ui/src/library.rs:150`](../ui/dr-ui/src/library.rs#L150), [`ui/dr-ui/src/library.rs:167`](../ui/dr-ui/src/library.rs#L167), [`ui/dr-ui/src/library.rs:2020`](../ui/dr-ui/src/library.rs#L2020), [`ui/dr-ui/src/library.rs:2052`](../ui/dr-ui/src/library.rs#L2052), [`ui/dr-ui/src/library_ui.rs:112`](../ui/dr-ui/src/library_ui.rs#L112), [`ui/dr-ui/src/library_ui.rs:444`](../ui/dr-ui/src/library_ui.rs#L444), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:458`](../ui/dr-ui/ui/collections.slint#L458) |
|
| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:258`](../core/dr-catalog/src/schema.rs#L258), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:366`](../core/dr-sync-nextcloud/src/lib.rs#L366), [`core/dr-sync/src/lib.rs:122`](../core/dr-sync/src/lib.rs#L122), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1299`](../ui/dr-ui/src/collections_ui.rs#L1299), [`ui/dr-ui/src/collections_ui.rs:817`](../ui/dr-ui/src/collections_ui.rs#L817), [`ui/dr-ui/src/library.rs:150`](../ui/dr-ui/src/library.rs#L150), [`ui/dr-ui/src/library.rs:167`](../ui/dr-ui/src/library.rs#L167), [`ui/dr-ui/src/library.rs:2135`](../ui/dr-ui/src/library.rs#L2135), [`ui/dr-ui/src/library.rs:2167`](../ui/dr-ui/src/library.rs#L2167), [`ui/dr-ui/src/library_ui.rs:112`](../ui/dr-ui/src/library_ui.rs#L112), [`ui/dr-ui/src/library_ui.rs:476`](../ui/dr-ui/src/library_ui.rs#L476), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:458`](../ui/dr-ui/ui/collections.slint#L458) |
|
||||||
| FR-CAT-1a | [`core/dr-types/src/lib.rs:47`](../core/dr-types/src/lib.rs#L47) |
|
| FR-CAT-1a | [`core/dr-types/src/lib.rs:47`](../core/dr-types/src/lib.rs#L47) |
|
||||||
| FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) |
|
| FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) |
|
||||||
| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1) |
|
| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1) |
|
||||||
@@ -44,39 +44,42 @@ _None._
|
|||||||
| FR-CAT-5 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-decode/src/lib.rs:240`](../core/dr-decode/src/lib.rs#L240), [`core/dr-decode/src/lib.rs:307`](../core/dr-decode/src/lib.rs#L307), [`core/dr-pipeline/src/sidecar.rs:125`](../core/dr-pipeline/src/sidecar.rs#L125) |
|
| FR-CAT-5 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-decode/src/lib.rs:240`](../core/dr-decode/src/lib.rs#L240), [`core/dr-decode/src/lib.rs:307`](../core/dr-decode/src/lib.rs#L307), [`core/dr-pipeline/src/sidecar.rs:125`](../core/dr-pipeline/src/sidecar.rs#L125) |
|
||||||
| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/library.rs:177`](../ui/dr-ui/src/library.rs#L177) |
|
| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/library.rs:177`](../ui/dr-ui/src/library.rs#L177) |
|
||||||
| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
||||||
| FR-CAT-8 | [`core/dr-pipeline/src/sidecar.rs:89`](../core/dr-pipeline/src/sidecar.rs#L89), [`ui/dr-ui/src/library.rs:289`](../ui/dr-ui/src/library.rs#L289) |
|
| FR-CAT-8 | [`core/dr-pipeline/src/sidecar.rs:89`](../core/dr-pipeline/src/sidecar.rs#L89), [`ui/dr-ui/src/develop.rs:820`](../ui/dr-ui/src/develop.rs#L820), [`ui/dr-ui/src/lib.rs:1114`](../ui/dr-ui/src/lib.rs#L1114), [`ui/dr-ui/src/lib.rs:1214`](../ui/dr-ui/src/lib.rs#L1214), [`ui/dr-ui/src/lib.rs:405`](../ui/dr-ui/src/lib.rs#L405), [`ui/dr-ui/src/lib.rs:607`](../ui/dr-ui/src/lib.rs#L607), [`ui/dr-ui/src/lib.rs:815`](../ui/dr-ui/src/lib.rs#L815), [`ui/dr-ui/src/library.rs:1075`](../ui/dr-ui/src/library.rs#L1075), [`ui/dr-ui/src/library.rs:289`](../ui/dr-ui/src/library.rs#L289), [`ui/dr-ui/src/library_ui.rs:2995`](../ui/dr-ui/src/library_ui.rs#L2995) |
|
||||||
| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:104`](../core/dr-types/src/lib.rs#L104), [`ui/dr-ui/src/library.rs:136`](../ui/dr-ui/src/library.rs#L136), [`ui/dr-ui/src/library.rs:194`](../ui/dr-ui/src/library.rs#L194), [`ui/dr-ui/src/library.rs:2140`](../ui/dr-ui/src/library.rs#L2140), [`ui/dr-ui/src/library.rs:994`](../ui/dr-ui/src/library.rs#L994), [`ui/dr-ui/src/library_ui.rs:1024`](../ui/dr-ui/src/library_ui.rs#L1024), [`ui/dr-ui/src/library_ui.rs:1286`](../ui/dr-ui/src/library_ui.rs#L1286), [`ui/dr-ui/src/library_ui.rs:1437`](../ui/dr-ui/src/library_ui.rs#L1437), [`ui/dr-ui/src/library_ui.rs:165`](../ui/dr-ui/src/library_ui.rs#L165), [`ui/dr-ui/src/library_ui.rs:1756`](../ui/dr-ui/src/library_ui.rs#L1756), [`ui/dr-ui/src/library_ui.rs:1828`](../ui/dr-ui/src/library_ui.rs#L1828), [`ui/dr-ui/src/library_ui.rs:1954`](../ui/dr-ui/src/library_ui.rs#L1954), [`ui/dr-ui/src/library_ui.rs:291`](../ui/dr-ui/src/library_ui.rs#L291), [`ui/dr-ui/src/library_ui.rs:2953`](../ui/dr-ui/src/library_ui.rs#L2953), [`ui/dr-ui/src/library_ui.rs:2981`](../ui/dr-ui/src/library_ui.rs#L2981), [`ui/dr-ui/src/library_ui.rs:982`](../ui/dr-ui/src/library_ui.rs#L982) |
|
| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:104`](../core/dr-types/src/lib.rs#L104), [`ui/dr-ui/src/library.rs:1035`](../ui/dr-ui/src/library.rs#L1035), [`ui/dr-ui/src/library.rs:136`](../ui/dr-ui/src/library.rs#L136), [`ui/dr-ui/src/library.rs:194`](../ui/dr-ui/src/library.rs#L194), [`ui/dr-ui/src/library.rs:2255`](../ui/dr-ui/src/library.rs#L2255), [`ui/dr-ui/src/library_ui.rs:1014`](../ui/dr-ui/src/library_ui.rs#L1014), [`ui/dr-ui/src/library_ui.rs:1056`](../ui/dr-ui/src/library_ui.rs#L1056), [`ui/dr-ui/src/library_ui.rs:1318`](../ui/dr-ui/src/library_ui.rs#L1318), [`ui/dr-ui/src/library_ui.rs:1583`](../ui/dr-ui/src/library_ui.rs#L1583), [`ui/dr-ui/src/library_ui.rs:165`](../ui/dr-ui/src/library_ui.rs#L165), [`ui/dr-ui/src/library_ui.rs:1902`](../ui/dr-ui/src/library_ui.rs#L1902), [`ui/dr-ui/src/library_ui.rs:1974`](../ui/dr-ui/src/library_ui.rs#L1974), [`ui/dr-ui/src/library_ui.rs:2139`](../ui/dr-ui/src/library_ui.rs#L2139), [`ui/dr-ui/src/library_ui.rs:291`](../ui/dr-ui/src/library_ui.rs#L291), [`ui/dr-ui/src/library_ui.rs:3147`](../ui/dr-ui/src/library_ui.rs#L3147), [`ui/dr-ui/src/library_ui.rs:3175`](../ui/dr-ui/src/library_ui.rs#L3175) |
|
||||||
| FR-CULL-1 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) |
|
| FR-CULL-1 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) |
|
||||||
| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161) |
|
| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161) |
|
||||||
| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:125`](../core/dr-pipeline/src/sidecar.rs#L125), [`ui/dr-ui/src/library.rs:177`](../ui/dr-ui/src/library.rs#L177), [`ui/dr-ui/src/library.rs:289`](../ui/dr-ui/src/library.rs#L289) |
|
| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:125`](../core/dr-pipeline/src/sidecar.rs#L125), [`ui/dr-ui/src/library.rs:177`](../ui/dr-ui/src/library.rs#L177), [`ui/dr-ui/src/library.rs:289`](../ui/dr-ui/src/library.rs#L289) |
|
||||||
| FR-DEV-3 | [`core/dr-pipeline/src/framing.rs:174`](../core/dr-pipeline/src/framing.rs#L174) |
|
| FR-DEV-3 | [`core/dr-pipeline/src/framing.rs:185`](../core/dr-pipeline/src/framing.rs#L185) |
|
||||||
| FR-DEV-3a | [`core/dr-pipeline/build.rs:1551`](../core/dr-pipeline/build.rs#L1551), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:116`](../core/dr-pipeline/src/descriptor.rs#L116), [`core/dr-pipeline/src/descriptor.rs:91`](../core/dr-pipeline/src/descriptor.rs#L91), [`core/dr-pipeline/src/graph.rs:133`](../core/dr-pipeline/src/graph.rs#L133), [`core/dr-pipeline/src/graph.rs:17`](../core/dr-pipeline/src/graph.rs#L17), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/operation.rs:104`](../core/dr-pipeline/src/operation.rs#L104) |
|
| FR-DEV-3a | [`core/dr-pipeline/build.rs:1734`](../core/dr-pipeline/build.rs#L1734), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:256`](../core/dr-pipeline/src/framing.rs#L256), [`core/dr-pipeline/src/graph.rs:133`](../core/dr-pipeline/src/graph.rs#L133), [`core/dr-pipeline/src/graph.rs:17`](../core/dr-pipeline/src/graph.rs#L17), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/operation.rs:104`](../core/dr-pipeline/src/operation.rs#L104) |
|
||||||
| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:91`](../core/dr-pipeline/src/descriptor.rs#L91), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/operation.rs:104`](../core/dr-pipeline/src/operation.rs#L104) |
|
| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:256`](../core/dr-pipeline/src/framing.rs#L256), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/operation.rs:104`](../core/dr-pipeline/src/operation.rs#L104) |
|
||||||
| FR-DEV-3c | [`core/dr-pipeline/build.rs:1551`](../core/dr-pipeline/build.rs#L1551), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:133`](../core/dr-pipeline/src/graph.rs#L133) |
|
| FR-DEV-3c | [`core/dr-pipeline/build.rs:1734`](../core/dr-pipeline/build.rs#L1734), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:133`](../core/dr-pipeline/src/graph.rs#L133), [`ui/dr-ui/src/develop.rs:1258`](../ui/dr-ui/src/develop.rs#L1258) |
|
||||||
| FR-DEV-3d | [`core/dr-pipeline/src/framing.rs:174`](../core/dr-pipeline/src/framing.rs#L174) |
|
| FR-DEV-3d | [`core/dr-pipeline/src/framing.rs:185`](../core/dr-pipeline/src/framing.rs#L185) |
|
||||||
| FR-DEV-3e | [`core/dr-decode/src/lib.rs:492`](../core/dr-decode/src/lib.rs#L492), [`core/dr-decode/src/lib.rs:612`](../core/dr-decode/src/lib.rs#L612) |
|
| FR-DEV-3e | [`core/dr-decode/src/lib.rs:492`](../core/dr-decode/src/lib.rs#L492), [`core/dr-decode/src/lib.rs:612`](../core/dr-decode/src/lib.rs#L612) |
|
||||||
| FR-DEV-3h | [`core/dr-decode/src/lib.rs:307`](../core/dr-decode/src/lib.rs#L307), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-types/src/lib.rs:272`](../core/dr-types/src/lib.rs#L272) |
|
| FR-DEV-3h | [`core/dr-decode/src/lib.rs:307`](../core/dr-decode/src/lib.rs#L307), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:199`](../core/dr-pipeline/src/framing.rs#L199), [`core/dr-types/src/lib.rs:272`](../core/dr-types/src/lib.rs#L272) |
|
||||||
| FR-DEV-4 | [`core/dr-gpu/src/lib.rs:132`](../core/dr-gpu/src/lib.rs#L132) |
|
| FR-DEV-4 | [`core/dr-gpu/src/lib.rs:132`](../core/dr-gpu/src/lib.rs#L132) |
|
||||||
| FR-DSP-1 | [`ui/dr-ui/src/lib.rs:45`](../ui/dr-ui/src/lib.rs#L45) |
|
| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:61`](../core/dr-types/src/settings.rs#L61), [`ui/dr-ui/src/develop.rs:801`](../ui/dr-ui/src/develop.rs#L801), [`ui/dr-ui/src/develop.rs:811`](../ui/dr-ui/src/develop.rs#L811), [`ui/dr-ui/src/lib.rs:815`](../ui/dr-ui/src/lib.rs#L815), [`ui/dr-ui/src/library.rs:1075`](../ui/dr-ui/src/library.rs#L1075), [`ui/dr-ui/src/library.rs:289`](../ui/dr-ui/src/library.rs#L289), [`ui/dr-ui/src/library.rs:317`](../ui/dr-ui/src/library.rs#L317), [`ui/dr-ui/src/library_ui.rs:1414`](../ui/dr-ui/src/library_ui.rs#L1414), [`ui/dr-ui/src/library_ui.rs:317`](../ui/dr-ui/src/library_ui.rs#L317), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:434`](../ui/dr-ui/src/settings_ui.rs#L434), [`ui/dr-ui/ui/adjust.slint:569`](../ui/dr-ui/ui/adjust.slint#L569), [`ui/dr-ui/ui/library.slint:455`](../ui/dr-ui/ui/library.slint#L455), [`ui/dr-ui/ui/library.slint:474`](../ui/dr-ui/ui/library.slint#L474), [`ui/dr-ui/ui/library.slint:748`](../ui/dr-ui/ui/library.slint#L748), [`ui/dr-ui/ui/settings.slint:79`](../ui/dr-ui/ui/settings.slint#L79) |
|
||||||
|
| FR-DSP-1 | [`ui/dr-ui/src/lib.rs:47`](../ui/dr-ui/src/lib.rs#L47) |
|
||||||
| FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
| FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||||
| FR-EXP-2 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
| FR-EXP-2 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||||
| FR-EXP-3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
| FR-EXP-3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||||
| FR-EXP-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
| FR-EXP-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||||
| FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
|
| FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
|
||||||
| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:291`](../ui/dr-ui/src/lib.rs#L291), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||||
|
| FR-EXP-7 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/library_ui.rs:1991`](../ui/dr-ui/src/library_ui.rs#L1991) |
|
||||||
| FR-EXP-8 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
| FR-EXP-8 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||||
| FR-EXP-9 | [`core/dr-decode/src/lib.rs:409`](../core/dr-decode/src/lib.rs#L409), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:91`](../core/dr-export/src/lib.rs#L91), [`core/dr-gpu/src/adjust.rs:332`](../core/dr-gpu/src/adjust.rs#L332) |
|
| FR-EXP-9 | [`core/dr-decode/src/lib.rs:409`](../core/dr-decode/src/lib.rs#L409), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:91`](../core/dr-export/src/lib.rs#L91), [`core/dr-gpu/src/adjust.rs:332`](../core/dr-gpu/src/adjust.rs#L332), [`ui/dr-ui/src/develop.rs:542`](../ui/dr-ui/src/develop.rs#L542), [`ui/dr-ui/src/lib.rs:291`](../ui/dr-ui/src/lib.rs#L291) |
|
||||||
| FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`ui/dr-ui/src/launch.rs:256`](../ui/dr-ui/src/launch.rs#L256), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49), [`ui/dr-ui/src/launch_ui.rs:344`](../ui/dr-ui/src/launch_ui.rs#L344) |
|
| FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`ui/dr-ui/src/launch.rs:256`](../ui/dr-ui/src/launch.rs#L256), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49), [`ui/dr-ui/src/launch_ui.rs:344`](../ui/dr-ui/src/launch_ui.rs#L344) |
|
||||||
|
| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/library_ui.rs:1991`](../ui/dr-ui/src/library_ui.rs#L1991) |
|
||||||
| FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync-nextcloud/src/lib.rs:811`](../core/dr-sync-nextcloud/src/lib.rs#L811), [`core/dr-sync/src/lib.rs:155`](../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/lib.rs:38`](../core/dr-sync/src/lib.rs#L38), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1) |
|
| FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync-nextcloud/src/lib.rs:811`](../core/dr-sync-nextcloud/src/lib.rs#L811), [`core/dr-sync/src/lib.rs:155`](../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/lib.rs:38`](../core/dr-sync/src/lib.rs#L38), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1) |
|
||||||
| FR-NC-2 | [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`core/dr-sync-nextcloud/src/session.rs:34`](../core/dr-sync-nextcloud/src/session.rs#L34) |
|
| FR-NC-2 | [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`core/dr-sync-nextcloud/src/session.rs:34`](../core/dr-sync-nextcloud/src/session.rs#L34) |
|
||||||
| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) |
|
| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) |
|
||||||
| FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:155`](../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) |
|
| FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:155`](../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) |
|
||||||
| FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51) |
|
| FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51) |
|
||||||
| FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) |
|
| FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) |
|
||||||
| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-catalog/src/schema.rs:631`](../core/dr-catalog/src/schema.rs#L631), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/lib.rs:1347`](../ui/dr-ui/src/lib.rs#L1347), [`ui/dr-ui/src/lib.rs:897`](../ui/dr-ui/src/lib.rs#L897), [`ui/dr-ui/src/library.rs:813`](../ui/dr-ui/src/library.rs#L813), [`ui/dr-ui/src/library.rs:836`](../ui/dr-ui/src/library.rs#L836), [`ui/dr-ui/src/library.rs:994`](../ui/dr-ui/src/library.rs#L994), [`ui/dr-ui/src/library_ui.rs:1087`](../ui/dr-ui/src/library_ui.rs#L1087), [`ui/dr-ui/src/library_ui.rs:173`](../ui/dr-ui/src/library_ui.rs#L173), [`ui/dr-ui/src/library_ui.rs:184`](../ui/dr-ui/src/library_ui.rs#L184), [`ui/dr-ui/src/library_ui.rs:193`](../ui/dr-ui/src/library_ui.rs#L193), [`ui/dr-ui/src/library_ui.rs:250`](../ui/dr-ui/src/library_ui.rs#L250), [`ui/dr-ui/src/library_ui.rs:260`](../ui/dr-ui/src/library_ui.rs#L260), [`ui/dr-ui/src/library_ui.rs:2970`](../ui/dr-ui/src/library_ui.rs#L2970), [`ui/dr-ui/src/library_ui.rs:302`](../ui/dr-ui/src/library_ui.rs#L302), [`ui/dr-ui/src/library_ui.rs:317`](../ui/dr-ui/src/library_ui.rs#L317), [`ui/dr-ui/src/library_ui.rs:348`](../ui/dr-ui/src/library_ui.rs#L348), [`ui/dr-ui/src/library_ui.rs:729`](../ui/dr-ui/src/library_ui.rs#L729), [`ui/dr-ui/src/library_ui.rs:830`](../ui/dr-ui/src/library_ui.rs#L830), [`ui/dr-ui/src/library_ui.rs:949`](../ui/dr-ui/src/library_ui.rs#L949), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/library.slint:771`](../ui/dr-ui/ui/library.slint#L771) |
|
| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-catalog/src/schema.rs:631`](../core/dr-catalog/src/schema.rs#L631), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/lib.rs:1140`](../ui/dr-ui/src/lib.rs#L1140), [`ui/dr-ui/src/lib.rs:1668`](../ui/dr-ui/src/lib.rs#L1668), [`ui/dr-ui/src/library.rs:1035`](../ui/dr-ui/src/library.rs#L1035), [`ui/dr-ui/src/library.rs:854`](../ui/dr-ui/src/library.rs#L854), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1119`](../ui/dr-ui/src/library_ui.rs#L1119), [`ui/dr-ui/src/library_ui.rs:173`](../ui/dr-ui/src/library_ui.rs#L173), [`ui/dr-ui/src/library_ui.rs:184`](../ui/dr-ui/src/library_ui.rs#L184), [`ui/dr-ui/src/library_ui.rs:193`](../ui/dr-ui/src/library_ui.rs#L193), [`ui/dr-ui/src/library_ui.rs:250`](../ui/dr-ui/src/library_ui.rs#L250), [`ui/dr-ui/src/library_ui.rs:260`](../ui/dr-ui/src/library_ui.rs#L260), [`ui/dr-ui/src/library_ui.rs:302`](../ui/dr-ui/src/library_ui.rs#L302), [`ui/dr-ui/src/library_ui.rs:3164`](../ui/dr-ui/src/library_ui.rs#L3164), [`ui/dr-ui/src/library_ui.rs:349`](../ui/dr-ui/src/library_ui.rs#L349), [`ui/dr-ui/src/library_ui.rs:380`](../ui/dr-ui/src/library_ui.rs#L380), [`ui/dr-ui/src/library_ui.rs:761`](../ui/dr-ui/src/library_ui.rs#L761), [`ui/dr-ui/src/library_ui.rs:862`](../ui/dr-ui/src/library_ui.rs#L862), [`ui/dr-ui/src/library_ui.rs:981`](../ui/dr-ui/src/library_ui.rs#L981), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/library.slint:504`](../ui/dr-ui/ui/library.slint#L504) |
|
||||||
| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:104`](../core/dr-types/src/lib.rs#L104), [`core/dr-types/src/lib.rs:186`](../core/dr-types/src/lib.rs#L186), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) |
|
| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:104`](../core/dr-types/src/lib.rs#L104), [`core/dr-types/src/lib.rs:186`](../core/dr-types/src/lib.rs#L186), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) |
|
||||||
| FR-NC-7 | [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1) |
|
| FR-NC-7 | [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1) |
|
||||||
| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:108`](../core/dr-pipeline/src/sidecar.rs#L108), [`core/dr-pipeline/src/sidecar.rs:89`](../core/dr-pipeline/src/sidecar.rs#L89), [`ui/dr-ui/src/library.rs:289`](../ui/dr-ui/src/library.rs#L289) |
|
| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:108`](../core/dr-pipeline/src/sidecar.rs#L108), [`core/dr-pipeline/src/sidecar.rs:89`](../core/dr-pipeline/src/sidecar.rs#L89), [`ui/dr-ui/src/lib.rs:1114`](../ui/dr-ui/src/lib.rs#L1114), [`ui/dr-ui/src/library.rs:289`](../ui/dr-ui/src/library.rs#L289), [`ui/dr-ui/src/library_ui.rs:317`](../ui/dr-ui/src/library_ui.rs#L317) |
|
||||||
| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:212`](../core/dr-pipeline/src/sidecar.rs#L212) |
|
| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:212`](../core/dr-pipeline/src/sidecar.rs#L212) |
|
||||||
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:47`](../core/dr-types/src/lib.rs#L47) |
|
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:47`](../core/dr-types/src/lib.rs#L47) |
|
||||||
| FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
|
| FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
|
||||||
@@ -85,11 +88,12 @@ _None._
|
|||||||
| FR-RAW-3 | [`core/dr-decode/src/lib.rs:409`](../core/dr-decode/src/lib.rs#L409), [`core/dr-decode/src/lib.rs:94`](../core/dr-decode/src/lib.rs#L94) |
|
| FR-RAW-3 | [`core/dr-decode/src/lib.rs:409`](../core/dr-decode/src/lib.rs#L409), [`core/dr-decode/src/lib.rs:94`](../core/dr-decode/src/lib.rs#L94) |
|
||||||
| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) |
|
| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) |
|
||||||
| FR-RAW-5 | [`core/dr-decode/src/lib.rs:122`](../core/dr-decode/src/lib.rs#L122) |
|
| FR-RAW-5 | [`core/dr-decode/src/lib.rs:122`](../core/dr-decode/src/lib.rs#L122) |
|
||||||
| FR-UI-1 | [`ui/dr-ui/src/lib.rs:1429`](../ui/dr-ui/src/lib.rs#L1429), [`ui/dr-ui/src/lib.rs:53`](../ui/dr-ui/src/lib.rs#L53) |
|
| FR-UI-1 | [`ui/dr-ui/src/lib.rs:1750`](../ui/dr-ui/src/lib.rs#L1750), [`ui/dr-ui/src/lib.rs:55`](../ui/dr-ui/src/lib.rs#L55), [`ui/dr-ui/ui/library.slint:546`](../ui/dr-ui/ui/library.slint#L546) |
|
||||||
| FR-UI-2 | [`ui/dr-ui/src/lib.rs:53`](../ui/dr-ui/src/lib.rs#L53) |
|
| FR-UI-2 | [`ui/dr-ui/src/lib.rs:55`](../ui/dr-ui/src/lib.rs#L55) |
|
||||||
| FR-UI-3 | [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
| FR-UI-3 | [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
||||||
| FR-UI-4 | [`ui/dr-ui/ui/app.slint:992`](../ui/dr-ui/ui/app.slint#L992) |
|
| FR-UI-4 | [`ui/dr-ui/ui/app.slint:1064`](../ui/dr-ui/ui/app.slint#L1064) |
|
||||||
| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1463`](../ui/dr-ui/src/lib.rs#L1463), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1784`](../ui/dr-ui/src/lib.rs#L1784), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
||||||
|
| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:100`](../core/dr-pipeline/src/descriptor.rs#L100), [`core/dr-pipeline/src/framing.rs:256`](../core/dr-pipeline/src/framing.rs#L256) |
|
||||||
| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
|
| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
|
||||||
| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1) |
|
| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1) |
|
||||||
| NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) |
|
| NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) |
|
||||||
@@ -101,7 +105,7 @@ _None._
|
|||||||
| NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) |
|
| NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) |
|
||||||
| NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
|
| NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
|
||||||
| NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
|
| NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
|
||||||
| NFR-RES-1 | [`ui/dr-ui/src/lib.rs:45`](../ui/dr-ui/src/lib.rs#L45) |
|
| NFR-RES-1 | [`ui/dr-ui/src/lib.rs:47`](../ui/dr-ui/src/lib.rs#L47) |
|
||||||
| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376) |
|
| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376) |
|
||||||
| NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) |
|
| NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) |
|
||||||
| R1 | [`tools/traceability/src/lib.rs:495`](../tools/traceability/src/lib.rs#L495), [`tools/traceability/src/lib.rs:499`](../tools/traceability/src/lib.rs#L499) |
|
| R1 | [`tools/traceability/src/lib.rs:495`](../tools/traceability/src/lib.rs#L495), [`tools/traceability/src/lib.rs:499`](../tools/traceability/src/lib.rs#L499) |
|
||||||
@@ -109,7 +113,7 @@ _None._
|
|||||||
|
|
||||||
## Not yet tagged
|
## Not yet tagged
|
||||||
|
|
||||||
78 of 151 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
|
74 of 151 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
|
||||||
|
|
||||||
<details><summary>Show untagged requirements</summary>
|
<details><summary>Show untagged requirements</summary>
|
||||||
|
|
||||||
@@ -130,7 +134,6 @@ _None._
|
|||||||
- FR-DEV-3f
|
- FR-DEV-3f
|
||||||
- FR-DEV-3g
|
- FR-DEV-3g
|
||||||
- FR-DEV-5
|
- FR-DEV-5
|
||||||
- FR-DEV-6
|
|
||||||
- FR-DEV-7
|
- FR-DEV-7
|
||||||
- FR-DEV-8
|
- FR-DEV-8
|
||||||
- FR-DSP-2
|
- FR-DSP-2
|
||||||
@@ -140,8 +143,6 @@ _None._
|
|||||||
- FR-DSP-6
|
- FR-DSP-6
|
||||||
- FR-DSP-7
|
- FR-DSP-7
|
||||||
- FR-DSP-8
|
- FR-DSP-8
|
||||||
- FR-EXP-7
|
|
||||||
- FR-NC-10
|
|
||||||
- FR-NC-11
|
- FR-NC-11
|
||||||
- FR-NC-6b
|
- FR-NC-6b
|
||||||
- FR-PLAT-AND-2
|
- FR-PLAT-AND-2
|
||||||
@@ -152,7 +153,6 @@ _None._
|
|||||||
- FR-PLAT-LIN-3
|
- FR-PLAT-LIN-3
|
||||||
- FR-RAW-2
|
- FR-RAW-2
|
||||||
- FR-UI-6
|
- FR-UI-6
|
||||||
- FR-UI-7
|
|
||||||
- NFR-A11Y-1
|
- NFR-A11Y-1
|
||||||
- NFR-A11Y-2
|
- NFR-A11Y-2
|
||||||
- NFR-A11Y-3
|
- NFR-A11Y-3
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ reqwest.workspace = true
|
|||||||
dr-plat.workspace = true
|
dr-plat.workspace = true
|
||||||
dr-sync.workspace = true
|
dr-sync.workspace = true
|
||||||
dr-sync-nextcloud.workspace = true
|
dr-sync-nextcloud.workspace = true
|
||||||
|
dr-export.workspace = true
|
||||||
dr-pipeline.workspace = true
|
dr-pipeline.workspace = true
|
||||||
dr-catalog.workspace = true
|
dr-catalog.workspace = true
|
||||||
dr-thumbs.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_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext};
|
||||||
use dr_pipeline::ops::curve;
|
use dr_pipeline::ops::curve;
|
||||||
use dr_pipeline::{
|
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;
|
use crate::labels;
|
||||||
@@ -538,6 +539,35 @@ impl DevelopSession {
|
|||||||
self.graph.output_size(w, h)
|
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.
|
/// The sensor's own dimensions, before framing.
|
||||||
///
|
///
|
||||||
/// What a crop overlay needs: its handles are placed against the full
|
/// What a crop overlay needs: its handles are placed against the full
|
||||||
@@ -767,6 +797,36 @@ impl DevelopSession {
|
|||||||
pub fn is_neutral(&self) -> bool {
|
pub fn is_neutral(&self) -> bool {
|
||||||
self.graph.is_neutral()
|
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.
|
/// 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+326
-5
@@ -18,12 +18,14 @@ mod activity;
|
|||||||
mod collections_ui;
|
mod collections_ui;
|
||||||
mod derived_sync;
|
mod derived_sync;
|
||||||
mod develop;
|
mod develop;
|
||||||
|
mod export;
|
||||||
mod labels;
|
mod labels;
|
||||||
mod library;
|
mod library;
|
||||||
mod library_ui;
|
mod library_ui;
|
||||||
#[cfg(live_style)]
|
#[cfg(live_style)]
|
||||||
mod live_style;
|
mod live_style;
|
||||||
mod net_runtime;
|
mod net_runtime;
|
||||||
|
mod presets;
|
||||||
mod settings_store;
|
mod settings_store;
|
||||||
mod settings_ui;
|
mod settings_ui;
|
||||||
mod trash;
|
mod trash;
|
||||||
@@ -286,6 +288,109 @@ fn sync_framing(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>
|
|||||||
window.set_crop_h(crop.height);
|
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.
|
/// Push current parameter values back to the interface.
|
||||||
///
|
///
|
||||||
/// The controls are not self-updating: the core clamps values, so what the
|
/// 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
|
/// progress. The symptom is a slider that jumps on click but cannot be
|
||||||
/// dragged, because each move event destroys the thing that would deliver
|
/// dragged, because each move event destroys the thing that would deliver
|
||||||
/// the next one.
|
/// 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,
|
window: &AppWindow,
|
||||||
rows: &Rc<slint::VecModel<ParamRow>>,
|
rows: &Rc<slint::VecModel<ParamRow>>,
|
||||||
session: &Rc<RefCell<Option<DevelopSession>>>,
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||||||
@@ -451,6 +604,14 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
let open_from_library: Rc<RefCell<Option<Rc<dyn Fn(String)>>>> = Rc::new(RefCell::new(None));
|
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
|
// Before anything binds to a token: the compiled palette is already in
|
||||||
// place, so this only overwrites what style.yaml currently says.
|
// place, so this only overwrites what style.yaml currently says.
|
||||||
#[cfg(live_style)]
|
#[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
|
// develop side reads `paths` to rebuild its browsing list when an image is
|
||||||
// opened from the grid.
|
// opened from the grid.
|
||||||
let library = library_ui::LibraryController::new(activity.clone());
|
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
|
// Launch screen: shown when there is nothing to display — no local paths
|
||||||
// and no configured library. A user who has already signed in and chosen
|
// and no configured library. A user who has already signed in and chosen
|
||||||
@@ -473,7 +638,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
window.set_show_launch(startup == launch::Startup::ShowLaunchScreen);
|
window.set_show_launch(startup == launch::Startup::ShowLaunchScreen);
|
||||||
|
|
||||||
let library = library.clone();
|
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
|
// The click handler needs `show`, which is built further down because
|
||||||
// it captures the develop session and the GPU context. This cell is
|
// 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
|
// 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.
|
// be reached until the window is running, by which point it is set.
|
||||||
let open_from_library = open_from_library.clone();
|
let open_from_library = open_from_library.clone();
|
||||||
library_ui::wire(&window, library.clone(), collections.clone(), move |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 {
|
let Some(f) = open_from_library.borrow().clone() else {
|
||||||
log::warn!("open requested before the viewer was ready: {path}");
|
log::warn!("open requested before the viewer was ready: {path}");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
f(path);
|
f(path);
|
||||||
});
|
},
|
||||||
|
Rc::new(move || {
|
||||||
|
if let Some(f) = leave_develop.borrow().clone() {
|
||||||
|
f();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// The collections sidebar shares the library's catalog handle rather
|
// The collections sidebar shares the library's catalog handle rather
|
||||||
// than opening its own: one SQLite connection, so an edit here is
|
// 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
|
// 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
|
// 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.
|
// 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
|
// What the cache actually holds, so the ceiling above it is a figure
|
||||||
// the user can judge rather than an abstract one.
|
// the user can judge rather than an abstract one.
|
||||||
settings.set_usage_label(describe_cache_usage(&library));
|
settings.set_usage_label(describe_cache_usage(&library));
|
||||||
// Rendered once up front so the page is correct the first time it is
|
// 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.
|
// opened, rather than on the second open after a callback has run.
|
||||||
settings_ui::render(&window, &settings);
|
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
|
// Apply what is on disk before anything can use it. Without this the
|
||||||
// controller's defaults stand until the user happens to open 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.
|
// cache would sit over budget indefinitely.
|
||||||
lib.set_cache_budget(s.cache.original_budget_bytes);
|
lib.set_cache_budget(s.cache.original_budget_bytes);
|
||||||
lib.set_keep_opened_originals(s.cache.keep_opened_originals);
|
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
|
// Lowering the ceiling evicts, so the figure beside it has just
|
||||||
// changed — leaving the old one would show the cache still over a
|
// 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.
|
// The current develop session, if the file yielded sensor data.
|
||||||
let session: Rc<RefCell<Option<DevelopSession>>> = Rc::new(RefCell::new(None));
|
let session: Rc<RefCell<Option<DevelopSession>>> = Rc::new(RefCell::new(None));
|
||||||
|
|
||||||
|
// TRACES: FR-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;
|
// One model for the lifetime of the window. Rows are mutated in place;
|
||||||
// see `sync_rows` for why replacing it breaks dragging.
|
// see `sync_rows` for why replacing it breaks dragging.
|
||||||
let rows: Rc<slint::VecModel<ParamRow>> = Rc::new(slint::VecModel::default());
|
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 redraw = redraw.clone();
|
||||||
let gpu = gpu.clone();
|
let gpu = gpu.clone();
|
||||||
let rows = rows.clone();
|
let rows = rows.clone();
|
||||||
|
let open_image = open_image.clone();
|
||||||
|
let library_for_show = library.clone();
|
||||||
Rc::new(move |window: &AppWindow| {
|
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();
|
let i = *index.borrow();
|
||||||
// Cloned rather than held: `load` below is slow, and keeping the
|
// Cloned rather than held: `load` below is slow, and keeping the
|
||||||
// list borrowed across it would panic the moment anything else
|
// list borrowed across it would panic the moment anything else
|
||||||
@@ -825,6 +1027,15 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
Some(s) => {
|
Some(s) => {
|
||||||
window.set_adjust_enabled(true);
|
window.set_adjust_enabled(true);
|
||||||
*session.borrow_mut() = Some(s);
|
*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
|
// Through `sync_rows` rather than setting rows
|
||||||
// directly, so the curve's drawn shape is
|
// directly, so the curve's drawn shape is
|
||||||
// refreshed by the same path that refreshes the
|
// 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
|
// the controls rather than offering sliders that
|
||||||
// would do nothing.
|
// would do nothing.
|
||||||
*session.borrow_mut() = None;
|
*session.borrow_mut() = None;
|
||||||
|
*open_image.borrow_mut() = presets::Stored::Nowhere;
|
||||||
rows.set_vec(Vec::<ParamRow>::new());
|
rows.set_vec(Vec::<ParamRow>::new());
|
||||||
window.set_adjust_enabled(false);
|
window.set_adjust_enabled(false);
|
||||||
if let Some(image) = l.fallback {
|
if let Some(image) = l.fallback {
|
||||||
@@ -874,9 +1086,14 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
let rows = rows.clone();
|
let rows = rows.clone();
|
||||||
let gpu = gpu.clone();
|
let gpu = gpu.clone();
|
||||||
let activity = activity.clone();
|
let activity = activity.clone();
|
||||||
|
let open_image = open_image.clone();
|
||||||
*open_from_library.borrow_mut() = Some(Rc::new(move |path: String| {
|
*open_from_library.borrow_mut() = Some(Rc::new(move |path: String| {
|
||||||
let Some(w) = weak.upgrade() else { return };
|
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();
|
let name = path.rsplit('/').next().unwrap_or(&path).to_string();
|
||||||
reset_view_state(&w);
|
reset_view_state(&w);
|
||||||
w.set_filename(name.clone().into());
|
w.set_filename(name.clone().into());
|
||||||
@@ -894,6 +1111,32 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
return;
|
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
|
// TRACES: FR-NC-6a
|
||||||
// The cache is consulted first, so a second open of the same
|
// The cache is consulted first, so a second open of the same
|
||||||
// photograph is a disk read rather than a second download of tens
|
// 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 redraw = redraw.clone();
|
||||||
let rows = rows.clone();
|
let rows = rows.clone();
|
||||||
let gpu = gpu.clone();
|
let gpu = gpu.clone();
|
||||||
|
let sidecar_rx = Rc::new(sidecar_rx);
|
||||||
let timer = Rc::new(slint::Timer::default());
|
let timer = Rc::new(slint::Timer::default());
|
||||||
let held = timer.clone();
|
let held = timer.clone();
|
||||||
timer.start(
|
timer.start(
|
||||||
@@ -967,6 +1211,20 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
Some(s) => {
|
Some(s) => {
|
||||||
w.set_adjust_enabled(true);
|
w.set_adjust_enabled(true);
|
||||||
*session.borrow_mut() = Some(s);
|
*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);
|
sync_rows(&w, &rows, &session);
|
||||||
redraw(&w);
|
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 ------------------------------------------
|
// ---- Adjustment callbacks ------------------------------------------
|
||||||
//
|
//
|
||||||
// Generic by construction: they carry indices into the capability list,
|
// Generic by construction: they carry indices into the capability list,
|
||||||
// so adding an operation needs no change here (FR-DEV-3c).
|
// 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 weak = window.as_weak();
|
||||||
let session = session.clone();
|
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
|
/// TRACES: FR-CAT-8 | FR-NC-8 | FR-CULL-4 | FR-DEV-6
|
||||||
/// One image's judgement, on its way to a sidecar.
|
/// One amendment to one image's sidecar, on its way to the server.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct JudgementWrite {
|
pub struct SidecarWrite {
|
||||||
/// Remote path of the *image*. The sidecar sits beside it, with the
|
/// Remote path of the *image*. The sidecar sits beside it, with the
|
||||||
/// extension replaced — that adjacency is what makes a sidecar findable
|
/// extension replaced — that adjacency is what makes a sidecar findable
|
||||||
/// without an index (ARCH §6.12).
|
/// without an index (ARCH §6.12).
|
||||||
pub image_path: String,
|
pub image_path: String,
|
||||||
pub version_uuid: String,
|
pub version_uuid: String,
|
||||||
pub rating: u8,
|
pub amendment: Amendment,
|
||||||
pub flag: u8,
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// Where an image's sidecar lives.
|
||||||
@@ -316,7 +344,7 @@ pub fn sidecar_path(image_path: &str) -> String {
|
|||||||
format!("{stem}.{}", dr_pipeline::sidecar::EXTENSION)
|
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
|
/// # Why this reads before it writes
|
||||||
///
|
///
|
||||||
@@ -337,7 +365,7 @@ pub fn sidecar_path(image_path: &str) -> String {
|
|||||||
pub fn spawn_sidecar_writes(
|
pub fn spawn_sidecar_writes(
|
||||||
creds: AppCredentials,
|
creds: AppCredentials,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
writes: Vec<JudgementWrite>,
|
writes: Vec<SidecarWrite>,
|
||||||
) -> Receiver<SidecarMessage> {
|
) -> Receiver<SidecarMessage> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
@@ -406,7 +434,7 @@ pub enum SidecarMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Read-modify-write one sidecar.
|
/// 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 path = RemotePath::new(sidecar_path(&w.image_path));
|
||||||
let id = RemoteId::Path(path.clone());
|
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
|
// not have one. The uuid comes from the catalog, so the same photograph
|
||||||
// keeps one identity across devices (FR-NC-8).
|
// keeps one identity across devices (FR-NC-8).
|
||||||
let mut version = sidecar
|
let mut version = sidecar
|
||||||
@@ -459,11 +487,24 @@ async fn write_one_sidecar(backend: &NextcloudBackend, w: &JudgementWrite) -> Re
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
version.rating = w.rating;
|
// Only what the amendment names. Everything else in the version — the
|
||||||
version.flag = w.flag;
|
// rating a settings write must not touch, the crop an adjustments-only
|
||||||
// A judgement is an edit as far as the merge is concerned: without the
|
// paste must spare, the unknown keys of an operation this build lacks —
|
||||||
// bump, a device that rated the same frame earlier would win on revision
|
// survives because it was read from the file and is written back.
|
||||||
// and this rating would be discarded at the next sync (FR-NC-9).
|
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.revision = version.revision.saturating_add(1);
|
||||||
version.modified = now_secs();
|
version.modified = now_secs();
|
||||||
|
|
||||||
@@ -1031,6 +1072,80 @@ pub struct CacheContext {
|
|||||||
///
|
///
|
||||||
/// Returns the bytes on a channel rather than blocking: the download runs on
|
/// 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.
|
/// 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(
|
pub fn spawn_full_fetch(
|
||||||
creds: AppCredentials,
|
creds: AppCredentials,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
|
|||||||
+198
-4
@@ -314,6 +314,38 @@ impl LibraryController {
|
|||||||
.map(|id| dr_types::ImageId(*id as u64))
|
.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
|
/// TRACES: FR-NC-6a
|
||||||
/// Where cached originals live for the open library.
|
/// 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.
|
/// Gather what the sidecar writer needs for each judged image.
|
||||||
///
|
///
|
||||||
/// The version uuid comes from the catalog rather than being generated here:
|
/// 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(
|
fn collect_sidecar_writes(
|
||||||
catalog: &Catalog,
|
catalog: &Catalog,
|
||||||
images: &[dr_types::ImageId],
|
images: &[dr_types::ImageId],
|
||||||
) -> Vec<library::JudgementWrite> {
|
) -> Vec<library::SidecarWrite> {
|
||||||
let placeholders = std::iter::repeat_n("?", images.len())
|
let placeholders = std::iter::repeat_n("?", images.len())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(",");
|
.join(",");
|
||||||
@@ -1407,11 +1551,13 @@ fn collect_sidecar_writes(
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||||
Ok(library::JudgementWrite {
|
Ok(library::SidecarWrite {
|
||||||
image_path: r.get(0)?,
|
image_path: r.get(0)?,
|
||||||
version_uuid: r.get(1)?,
|
version_uuid: r.get(1)?,
|
||||||
|
amendment: library::Amendment::Judgement {
|
||||||
rating: r.get::<_, i64>(2)? as u8,
|
rating: r.get::<_, i64>(2)? as u8,
|
||||||
flag: r.get::<_, i64>(3)? 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.
|
/// 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,
|
window: &AppWindow,
|
||||||
ctl: &Rc<LibraryController>,
|
ctl: &Rc<LibraryController>,
|
||||||
writes: Vec<library::JudgementWrite>,
|
writes: Vec<library::SidecarWrite>,
|
||||||
) {
|
) {
|
||||||
if writes.is_empty() {
|
if writes.is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -1842,6 +1988,45 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
|||||||
.unwrap_or_else(std::env::temp_dir);
|
.unwrap_or_else(std::env::temp_dir);
|
||||||
let _ = std::fs::create_dir_all(&scratch);
|
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);
|
window.set_library_syncing(true);
|
||||||
let rx = crate::derived_sync::spawn_sync(
|
let rx = crate::derived_sync::spawn_sync(
|
||||||
creds,
|
creds,
|
||||||
@@ -2448,6 +2633,7 @@ pub fn wire<F>(
|
|||||||
ctl: Rc<LibraryController>,
|
ctl: Rc<LibraryController>,
|
||||||
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
|
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
|
||||||
on_open_image: F,
|
on_open_image: F,
|
||||||
|
on_leave_develop: Rc<dyn Fn()>,
|
||||||
) where
|
) where
|
||||||
F: Fn(String) + 'static,
|
F: Fn(String) + 'static,
|
||||||
{
|
{
|
||||||
@@ -2806,6 +2992,14 @@ pub fn wire<F>(
|
|||||||
window.on_back_to_library(move || {
|
window.on_back_to_library(move || {
|
||||||
let Some(w) = weak.upgrade() else { return };
|
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();
|
let resume = ctl.resume_at.get();
|
||||||
if resume > 0 {
|
if resume > 0 {
|
||||||
// Through the same channel a scrub uses, and for the same
|
// 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_budget(budget::label(s.cache.thumbnail_budget_bytes).into());
|
||||||
window.set_settings_thumbnail_unlimited(s.cache.thumbnail_budget_bytes.is_none());
|
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_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());
|
window.set_settings_cache_usage(controller.usage_label.borrow().clone().into());
|
||||||
|
|
||||||
// --- export --------------------------------------------------------
|
// --- 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 weak = window.as_weak();
|
||||||
let ctl = controller.clone();
|
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.
|
// 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
|
// The column is closed as a whole from the status strip instead, which is the
|
||||||
// control that was actually wanted.
|
// 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 {
|
export component AdjustPanel inherits Rectangle {
|
||||||
in property <[ParamRow]> rows;
|
in property <[ParamRow]> rows;
|
||||||
in property <bool> enabled: true;
|
in property <bool> enabled: true;
|
||||||
|
|||||||
+88
-2
@@ -1,5 +1,5 @@
|
|||||||
import { Theme } from "theme.slint";
|
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 { LaunchScreen } from "launch.slint";
|
||||||
import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint";
|
import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint";
|
||||||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
||||||
@@ -24,10 +24,20 @@ component StatusBar inherits Rectangle {
|
|||||||
in property <bool> can-return-to-library: false;
|
in property <bool> can-return-to-library: false;
|
||||||
/// Whether the develop column is currently shown, for the toggle's label.
|
/// Whether the develop column is currently shown, for the toggle's label.
|
||||||
in property <bool> panel-visible: true;
|
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 back-to-library();
|
||||||
callback open-settings();
|
callback open-settings();
|
||||||
callback toggle-panel();
|
callback toggle-panel();
|
||||||
|
callback export-image();
|
||||||
|
|
||||||
// 44px and `surface`, the same bar the library and settings draw.
|
// 44px and `surface`, the same bar the library and settings draw.
|
||||||
//
|
//
|
||||||
@@ -88,6 +98,23 @@ component StatusBar inherits Rectangle {
|
|||||||
clicked => { root.toggle-panel(); }
|
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
|
// Reachable from develop as well as from the grid: export defaults are
|
||||||
// most likely to be wanted with a finished photograph on screen, which
|
// most likely to be wanted with a finished photograph on screen, which
|
||||||
// is exactly where this bar is and the library header is not.
|
// 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 curve-reset(int);
|
||||||
callback reset-all();
|
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) ---
|
// --- settings (FR-EXP-1, FR-EXP-3, FR-NC-6a) ---
|
||||||
//
|
//
|
||||||
// A page rather than an overlay, and the outermost of the view conditions
|
// A page rather than an overlay, and the outermost of the view conditions
|
||||||
@@ -498,6 +541,7 @@ export component AppWindow inherits Window {
|
|||||||
in property <string> settings-thumbnail-budget: "";
|
in property <string> settings-thumbnail-budget: "";
|
||||||
in property <bool> settings-thumbnail-unlimited: false;
|
in property <bool> settings-thumbnail-unlimited: false;
|
||||||
in property <bool> settings-keep-opened: true;
|
in property <bool> settings-keep-opened: true;
|
||||||
|
in property <bool> settings-copy-includes-framing: false;
|
||||||
in property <string> settings-cache-usage: "";
|
in property <string> settings-cache-usage: "";
|
||||||
|
|
||||||
callback settings-original-budget-changed(string);
|
callback settings-original-budget-changed(string);
|
||||||
@@ -505,6 +549,7 @@ export component AppWindow inherits Window {
|
|||||||
callback settings-thumbnail-budget-changed(string);
|
callback settings-thumbnail-budget-changed(string);
|
||||||
callback settings-thumbnail-unlimited-toggled(bool);
|
callback settings-thumbnail-unlimited-toggled(bool);
|
||||||
callback settings-keep-opened-toggled(bool);
|
callback settings-keep-opened-toggled(bool);
|
||||||
|
callback settings-copy-includes-framing-toggled(bool);
|
||||||
|
|
||||||
in property <[string]> settings-format-labels;
|
in property <[string]> settings-format-labels;
|
||||||
in property <int> settings-format-selected: 0;
|
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
|
/// 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
|
/// reason given above — a property read inside the layout and also feeding
|
||||||
/// it is a binding loop.
|
/// 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.
|
/// Show or hide the develop column.
|
||||||
callback toggle-panel();
|
callback toggle-panel();
|
||||||
|
/// Export the image on screen, using the settings as they stand.
|
||||||
|
callback export-image();
|
||||||
/// Show or hide the collections sidebar.
|
/// Show or hide the collections sidebar.
|
||||||
callback toggle-collections();
|
callback toggle-collections();
|
||||||
|
|
||||||
@@ -652,6 +706,7 @@ export component AppWindow inherits Window {
|
|||||||
thumbnail-budget: root.settings-thumbnail-budget;
|
thumbnail-budget: root.settings-thumbnail-budget;
|
||||||
thumbnail-unlimited: root.settings-thumbnail-unlimited;
|
thumbnail-unlimited: root.settings-thumbnail-unlimited;
|
||||||
keep-opened: root.settings-keep-opened;
|
keep-opened: root.settings-keep-opened;
|
||||||
|
copy-includes-framing: root.settings-copy-includes-framing;
|
||||||
cache-usage: root.settings-cache-usage;
|
cache-usage: root.settings-cache-usage;
|
||||||
|
|
||||||
original-budget-changed(t) => { root.settings-original-budget-changed(t); }
|
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);
|
root.settings-thumbnail-unlimited-toggled(on);
|
||||||
}
|
}
|
||||||
keep-opened-toggled(on) => { root.settings-keep-opened-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-labels: root.settings-format-labels;
|
||||||
format-selected: root.settings-format-selected;
|
format-selected: root.settings-format-selected;
|
||||||
@@ -833,6 +891,11 @@ export component AppWindow inherits Window {
|
|||||||
offset: root.library-offset;
|
offset: root.library-offset;
|
||||||
selected-count: root.library-selected-count;
|
selected-count: root.library-selected-count;
|
||||||
scope-label: root.collection-scope-label;
|
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-done: root.library-sweep-done;
|
||||||
sweep-total: root.library-sweep-total;
|
sweep-total: root.library-sweep-total;
|
||||||
@@ -929,9 +992,13 @@ export component AppWindow inherits Window {
|
|||||||
// grid to return to.
|
// grid to return to.
|
||||||
can-return-to-library: root.library-total > 0;
|
can-return-to-library: root.library-total > 0;
|
||||||
panel-visible: root.panel-visible;
|
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(); }
|
back-to-library() => { root.back-to-library(); }
|
||||||
open-settings() => { root.settings-open(); }
|
open-settings() => { root.settings-open(); }
|
||||||
toggle-panel() => { root.toggle-panel(); }
|
toggle-panel() => { root.toggle-panel(); }
|
||||||
|
export-image() => { root.export-image(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
@@ -1389,6 +1456,25 @@ export component AppWindow inherits Window {
|
|||||||
background: Theme.rule;
|
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 {
|
AdjustPanel {
|
||||||
vertical-stretch: 1;
|
vertical-stretch: 1;
|
||||||
rows: root.adjust-rows;
|
rows: root.adjust-rows;
|
||||||
|
|||||||
@@ -452,11 +452,16 @@ component HeaderActions inherits HorizontalLayout {
|
|||||||
in property <bool> scanning: false;
|
in property <bool> scanning: false;
|
||||||
in property <bool> syncing: false;
|
in property <bool> syncing: false;
|
||||||
in property <bool> scope-pinned: 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
|
/// Centres each button in a 44px header. Off in the disclosure row, which
|
||||||
/// is sized to its content.
|
/// is sized to its content.
|
||||||
in property <bool> centred: true;
|
in property <bool> centred: true;
|
||||||
in property <length> row-height: 44px;
|
in property <length> row-height: 44px;
|
||||||
|
|
||||||
|
callback paste-settings-to-selection();
|
||||||
callback remove-from-collection();
|
callback remove-from-collection();
|
||||||
callback change-library();
|
callback change-library();
|
||||||
callback toggle-pin-scope();
|
callback toggle-pin-scope();
|
||||||
@@ -466,6 +471,21 @@ component HeaderActions inherits HorizontalLayout {
|
|||||||
|
|
||||||
spacing: Theme.gap;
|
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
|
// Removing from a collection is only meaningful while the grid is scoped
|
||||||
// to one. Offering it unscoped would invite the reading "remove from the
|
// to one. Offering it unscoped would invite the reading "remove from the
|
||||||
// library", which nothing here does.
|
// library", which nothing here does.
|
||||||
@@ -725,6 +745,14 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
/// How many images are selected, for the header's count.
|
/// How many images are selected, for the header's count.
|
||||||
in property <int> selected-count: 0;
|
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 ------------------------------------------------
|
// --- the keyboard cursor ------------------------------------------------
|
||||||
//
|
//
|
||||||
// Where the keyboard is in the library, as an **image ordinal** — not a
|
// 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;
|
scanning: root.scanning;
|
||||||
syncing: root.syncing;
|
syncing: root.syncing;
|
||||||
scope-pinned: root.scope-pinned;
|
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(); }
|
remove-from-collection => { root.remove-from-collection(); }
|
||||||
change-library => { root.change-library(); }
|
change-library => { root.change-library(); }
|
||||||
toggle-pin-scope => { root.toggle-pin-scope(); }
|
toggle-pin-scope => { root.toggle-pin-scope(); }
|
||||||
@@ -905,6 +936,9 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
scanning: root.scanning;
|
scanning: root.scanning;
|
||||||
syncing: root.syncing;
|
syncing: root.syncing;
|
||||||
scope-pinned: root.scope-pinned;
|
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(); }
|
remove-from-collection => { root.remove-from-collection(); }
|
||||||
change-library => { root.change-library(); }
|
change-library => { root.change-library(); }
|
||||||
toggle-pin-scope => { root.toggle-pin-scope(); }
|
toggle-pin-scope => { root.toggle-pin-scope(); }
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ export component SettingsPage inherits Rectangle {
|
|||||||
in-out property <string> thumbnail-budget;
|
in-out property <string> thumbnail-budget;
|
||||||
in property <bool> thumbnail-unlimited: false;
|
in property <bool> thumbnail-unlimited: false;
|
||||||
in property <bool> keep-opened: true;
|
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.
|
/// What the cache currently holds. Empty hides the line.
|
||||||
in property <string> cache-usage;
|
in property <string> cache-usage;
|
||||||
|
|
||||||
@@ -84,6 +87,7 @@ export component SettingsPage inherits Rectangle {
|
|||||||
callback thumbnail-budget-changed(string);
|
callback thumbnail-budget-changed(string);
|
||||||
callback thumbnail-unlimited-toggled(bool);
|
callback thumbnail-unlimited-toggled(bool);
|
||||||
callback keep-opened-toggled(bool);
|
callback keep-opened-toggled(bool);
|
||||||
|
callback copy-includes-framing-toggled(bool);
|
||||||
|
|
||||||
// --- export --------------------------------------------------------
|
// --- export --------------------------------------------------------
|
||||||
in property <[string]> format-labels;
|
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 ----------------------------------------------
|
// --- export ----------------------------------------------
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: content.column;
|
width: content.column;
|
||||||
|
|||||||
Reference in New Issue
Block a user