diff --git a/Cargo.lock b/Cargo.lock index f12a6ae..f654272 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1472,6 +1472,7 @@ dependencies = [ "anyhow", "dr-catalog", "dr-decode", + "dr-export", "dr-gpu", "dr-pipeline", "dr-plat", diff --git a/core/dr-pipeline/src/lib.rs b/core/dr-pipeline/src/lib.rs index a9a15fe..cd0bc28 100644 --- a/core/dr-pipeline/src/lib.rs +++ b/core/dr-pipeline/src/lib.rs @@ -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)] diff --git a/core/dr-pipeline/src/preset.rs b/core/dr-pipeline/src/preset.rs new file mode 100644 index 0000000..0d2a9be --- /dev/null +++ b/core/dr-pipeline/src/preset.rs @@ -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]); + } +} diff --git a/core/dr-pipeline/src/sidecar.rs b/core/dr-pipeline/src/sidecar.rs index e1a60bc..3350848 100644 --- a/core/dr-pipeline/src/sidecar.rs +++ b/core/dr-pipeline/src/sidecar.rs @@ -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 { diff --git a/core/dr-types/src/lib.rs b/core/dr-types/src/lib.rs index c088e1e..3fbeea9 100644 --- a/core/dr-types/src/lib.rs +++ b/core/dr-types/src/lib.rs @@ -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 diff --git a/core/dr-types/src/settings.rs b/core/dr-types/src/settings.rs index dccd83f..110265f 100644 --- a/core/dr-types/src/settings.rs +++ b/core/dr-types/src/settings.rs @@ -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, } // --------------------------------------------------------------------------- diff --git a/docs/traceability.md b/docs/traceability.md index 3a352c8..b889a59 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -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.
Show untagged requirements @@ -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 diff --git a/ui/dr-ui/Cargo.toml b/ui/dr-ui/Cargo.toml index 4aca3c9..0119909 100644 --- a/ui/dr-ui/Cargo.toml +++ b/ui/dr-ui/Cargo.toml @@ -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 diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index 9311945..da43951 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -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 { + 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. diff --git a/ui/dr-ui/src/export.rs b/ui/dr-ui/src/export.rs new file mode 100644 index 0000000..1a88ff0 --- /dev/null +++ b/ui/dr-ui/src/export.rs @@ -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 `.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 { + 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 { + 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 { + 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, + }, +} + +/// 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 { + 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")); + } +} diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index 3622881..be31362 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -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> 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>>, + settings: &Rc, + library: &Rc, +) -> Result { + 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) { + 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> /// 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>>, + session: &Rc>>, + rows: &Rc>, + redraw: &Rc, +) { + // 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>, session: &Rc>>, @@ -451,6 +604,14 @@ pub fn run(paths: Vec) -> Result<()> { #[allow(clippy::type_complexity)] let open_from_library: Rc>>> = 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>>> = 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> Result<()> { // The current develop session, if the file yielded sensor data. let session: Rc>> = 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> = Rc::new(slint::VecModel::default()); @@ -793,7 +988,14 @@ pub fn run(paths: Vec) -> 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) -> 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) -> 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::::new()); window.set_adjust_enabled(false); if let Some(image) = l.fallback { @@ -874,9 +1086,14 @@ pub fn run(paths: Vec) -> 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) -> 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) -> 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) -> 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) -> 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(); diff --git a/ui/dr-ui/src/library.rs b/ui/dr-ui/src/library.rs index 33961cc..2d59544 100644 --- a/ui/dr-ui/src/library.rs +++ b/ui/dr-ui/src/library.rs @@ -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, + writes: Vec, ) -> Receiver { 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> { + 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, diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index 39b82be..a23b472 100644 --- a/ui/dr-ui/src/library_ui.rs +++ b/ui/dr-ui/src/library_ui.rs @@ -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 { + 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, flag: Option, + 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 { + let placeholders = std::iter::repeat_n("?", images.len()) + .collect::>() + .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 = 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, flag: Option Vec { +) -> Vec { let placeholders = std::iter::repeat_n("?", images.len()) .collect::>() .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, - writes: Vec, + writes: Vec, ) { if writes.is_empty() { return; @@ -1842,6 +1988,45 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc) { .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( ctl: Rc, coll_ctl: Rc, on_open_image: F, + on_leave_develop: Rc, ) where F: Fn(String) + 'static, { @@ -2806,6 +2992,14 @@ pub fn wire( 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 diff --git a/ui/dr-ui/src/presets.rs b/ui/dr-ui/src/presets.rs new file mode 100644 index 0000000..d1c0a41 --- /dev/null +++ b/ui/dr-ui/src/presets.rs @@ -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>; + +/// 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>, +} + +impl Clipboard { + pub fn new() -> Rc { + 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 { + 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 { + 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>>, + library: &Rc, +) { + 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>>, + rows: &Rc>, +) -> 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, + settings: &Rc, +) { + 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>>, + pub rows: Rc>, + pub redraw: Rc, + /// Where the open image's edit is stored. + pub open: OpenImage, +} + +/// Wire copy, paste and batch-paste. +pub fn wire( + window: &AppWindow, + clipboard: Rc, + develop: Develop, + settings: Rc, + library: Rc, + collections: Rc, +) { + 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"); + } +} diff --git a/ui/dr-ui/src/settings_ui.rs b/ui/dr-ui/src/settings_ui.rs index 404f7f5..b730593 100644 --- a/ui/dr-ui/src/settings_ui.rs +++ b/ui/dr-ui/src/settings_ui.rs @@ -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(); diff --git a/ui/dr-ui/ui/adjust.slint b/ui/dr-ui/ui/adjust.slint index 96cc1f7..c9efe9e 100644 --- a/ui/dr-ui/ui/adjust.slint +++ b/ui/dr-ui/ui/adjust.slint @@ -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 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 armed: false; + /// What a paste would apply — "3 adjustments", or "Neutral". + in property summary; + /// Whether the clipboard holds framing the current scope is dropping. + /// Only then is it worth saying anything about the crop. + in property 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 enabled: true; diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index bcf896b..5292def 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -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 can-return-to-library: false; /// Whether the develop column is currently shown, for the toggle's label. in property 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 export-label: "Export"; + in property 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 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 settings-armed: false; + /// What a paste would apply, at the current scope — "3 adjustments". + in property settings-summary; + /// Whether the copy holds framing that the current setting is dropping. + in property 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 settings-thumbnail-budget: ""; in property settings-thumbnail-unlimited: false; in property settings-keep-opened: true; + in property settings-copy-includes-framing: false; in property 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 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 panel-visible: true; +in property 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 export-label: "Export"; + in property export-busy: false; + in property 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; diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index 7b31592..39a97be 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -452,11 +452,16 @@ component HeaderActions inherits HorizontalLayout { in property scanning: false; in property syncing: false; in property scope-pinned: false; + /// TRACES: FR-DEV-6 + /// Whether settings have been copied, and what pasting them would apply. + in property settings-armed: false; + in property settings-summary; /// Centres each button in a 44px header. Off in the disclosure row, which /// is sized to its content. in property centred: true; in property 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 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 settings-armed: false; + in property 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(); } diff --git a/ui/dr-ui/ui/settings.slint b/ui/dr-ui/ui/settings.slint index abc4ba1..40fc5dd 100644 --- a/ui/dr-ui/ui/settings.slint +++ b/ui/dr-ui/ui/settings.slint @@ -76,6 +76,9 @@ export component SettingsPage inherits Rectangle { in-out property thumbnail-budget; in property thumbnail-unlimited: false; in property keep-opened: true; + /// TRACES: FR-DEV-6 + /// Whether copying settings carries the crop and rotation with it. + in property copy-includes-framing: false; /// What the cache currently holds. Empty hides the line. in property 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;