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