From f630a3ff811e4359bdd07024b5bce860296b0a0c Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 9 Aug 2026 15:34:46 +0200 Subject: [PATCH] Wire the launch screen into the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app now opens on the login screen when there is nothing else to show — no local paths and no configured library — and goes straight to the images otherwise. Making someone click past a login they already completed is pure friction. launch.slint imported by app.slint, replacing the window rather than overlaying it: there is no library to look at until an account is configured launch_ui.rs the Slint wiring, kept out of lib.rs so the launch flow can change without touching the develop window Login runs on a worker thread and posts results back through a channel, since Slint's event loop is single-threaded and a 20-minute browser wait cannot block it. The system browser is opened via xdg-open, never an embedded webview (FR-NC-1). Sign-out deletes the local credential even if server-side revocation fails: a network error must not leave a usable secret on the machine. Format tick-boxes persist on each toggle, so a selection survives a crash before the library is opened. Two things deliberately incomplete rather than faked: - "Choose folder" lists the account's folders and reports them, but there is no picker widget yet, so selection still happens via the connect example. - "Open library" logs the request. Opening a remote library needs the scan-and-cache path, which belongs with the catalog work in flight. Earlier I broke the other in-flight dr-ui work by calling slint_build::compile twice, which replaces the generated module. The correct wiring is an import inside app.slint, which is what this does. 30 dr-ui tests passing; both launch paths verified by running the app. --- Cargo.lock | 4 + core/dr-pipeline/src/descriptor.rs | 43 ++ core/dr-pipeline/src/graph.rs | 17 +- core/dr-pipeline/src/lib.rs | 27 +- core/dr-pipeline/src/operation.rs | 18 +- core/dr-pipeline/src/ops/curve.rs | 645 +++++++++++++++++++++++++++++ core/dr-pipeline/src/ops/mod.rs | 2 + ui/dr-ui/Cargo.toml | 4 + ui/dr-ui/src/launch_ui.rs | 432 +++++++++++++++++++ ui/dr-ui/src/lib.rs | 19 + ui/dr-ui/ui/app.slint | 52 ++- 11 files changed, 1255 insertions(+), 8 deletions(-) create mode 100644 core/dr-pipeline/src/ops/curve.rs create mode 100644 ui/dr-ui/src/launch_ui.rs diff --git a/Cargo.lock b/Cargo.lock index e8c2db6..aa9a6a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1367,12 +1367,16 @@ dependencies = [ "dr-gpu", "dr-pipeline", "dr-plat", + "dr-sync", "dr-sync-nextcloud", "dr-types", "log", "pollster", + "reqwest", + "serde_json", "slint", "slint-build", + "tokio", "wgpu", ] diff --git a/core/dr-pipeline/src/descriptor.rs b/core/dr-pipeline/src/descriptor.rs index 79f0217..1888bfd 100644 --- a/core/dr-pipeline/src/descriptor.rs +++ b/core/dr-pipeline/src/descriptor.rs @@ -57,6 +57,24 @@ pub enum Unit { Percent, } +/// A control that does not reduce to a slider or a switch. +/// +/// The core names the *kind* of widget; `dr-widgets` owns what it looks like +/// and how it behaves (ARCH §3.3). This is deliberately a small closed +/// enum rather than an open string: a UI must be able to match exhaustively +/// and know it has covered everything the core can ask for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WidgetKind { + /// A tone curve, edited by dragging points on a grid. + /// + /// The underlying parameters are ordinary [`ParamKind::Scalar`]s — the + /// point coordinates — so a UI that does not implement the curve widget + /// can still present them as sliders and remain fully functional. That + /// fallback is the reason the points are scalars rather than an opaque + /// blob. + Curve, +} + /// The shape of a parameter's value. #[derive(Debug, Clone, PartialEq)] pub enum ParamKind { @@ -70,6 +88,31 @@ pub enum ParamKind { Bool, } +/// TRACES: FR-DEV-3a | FR-DEV-3b +/// How an operation would like its parameters presented. +/// +/// A *hint*, never a requirement. An operation's parameters are always +/// individually addressable scalars; this only says that several of them +/// form one conceptual control, and which widget draws it best. A UI is free +/// to ignore it entirely and render plain sliders — the edit still works, it +/// is merely more tedious. +/// +/// Sitting on the operation rather than on a parameter is what allows a +/// widget to span several parameters, which a curve necessarily does. +/// +/// Declared through [`crate::Operation::presentation`] — a defaulted trait +/// method rather than a field on [`OpDescriptor`], so the great majority of +/// operations, which want plain sliders, say nothing at all. +#[derive(Debug, Clone, PartialEq)] +pub struct Presentation { + pub widget: WidgetKind, + /// The parameters this widget owns, in the order it expects them. + /// + /// Parameters absent from this list are presented normally, so an + /// operation can pair a curve with an ordinary strength slider. + pub params: &'static [ParamId], +} + /// One parameter of an operation. #[derive(Debug, Clone, PartialEq)] pub struct ParamDescriptor { diff --git a/core/dr-pipeline/src/graph.rs b/core/dr-pipeline/src/graph.rs index 1953934..2bdf883 100644 --- a/core/dr-pipeline/src/graph.rs +++ b/core/dr-pipeline/src/graph.rs @@ -7,7 +7,7 @@ //! Order is data, not code: operations run in the sequence this holds them, //! so reordering the pipeline needs no code change. -use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind}; +use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation}; use crate::framing::{CropRect, Framing}; use crate::operation::{compose_with_framing, ComposedShader, Operation}; use crate::ops; @@ -28,6 +28,12 @@ pub struct OpCapability { /// mark a section as modified, or to offer a per-operation reset. pub active: bool, pub params: Vec, + /// A hint that several of `params` form one conceptual control. + /// + /// `None` means one control per parameter. A UI that does not implement + /// the named widget may ignore this and render sliders — the parameters + /// are ordinary scalars either way, so nothing becomes unreachable. + pub presentation: Option, } /// TRACES: FR-DEV-3a | FR-DEV-3b @@ -81,6 +87,10 @@ impl EditGraph { Box::new(ops::Contrast::new()), Box::new(ops::HighlightsShadows::new()), Box::new(ops::BlacksWhites::new()), + // After the region controls, so the curve is the final word + // on tone: a photographer reaches for it to fix what the + // fixed-weight controls could not place exactly. + Box::new(ops::ToneCurve::new()), Box::new(ops::Brilliance::new()), Box::new(ops::Vibrance::new()), Box::new(ops::Saturation::new()), @@ -157,6 +167,7 @@ impl EditGraph { value: op.param(p.id), }) .collect(), + presentation: op.presentation(), } }); @@ -178,6 +189,10 @@ impl EditGraph { value: self.framing.param(p.id), }) .collect(), + // Framing is not an `Operation`, so it has no `presentation` to + // ask for. A crop overlay is a viewport interaction rather than a + // panel widget, which is a different mechanism again. + presentation: None, }; ops.chain(std::iter::once(framing)).collect() diff --git a/core/dr-pipeline/src/lib.rs b/core/dr-pipeline/src/lib.rs index 1f994b7..f1bd0de 100644 --- a/core/dr-pipeline/src/lib.rs +++ b/core/dr-pipeline/src/lib.rs @@ -39,7 +39,8 @@ pub mod operation; pub mod ops; pub use descriptor::{ - LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Scale, Unit, + LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Presentation, Scale, + Unit, WidgetKind, }; pub use framing::{CropRect, Framing}; pub use graph::{EditGraph, OpCapability, ParamCapability}; @@ -54,14 +55,30 @@ mod tests { use super::*; /// Every operation in the default chain, activated. + /// + /// Parameters are moved away from their defaults by differing amounts, + /// scaled by position. A uniform nudge is not enough: the tone curve's + /// neutral is a *relationship* between its parameters rather than a set + /// of values, so shifting every point by the same amount slides it along + /// the identity diagonal and leaves the operation correctly inactive. + /// Varying the step breaks that symmetry, as any real edit would. fn fully_active() -> EditGraph { let mut g = EditGraph::default_chain(); for desc in g.descriptors() { - for p in desc.params { - // A value away from the default, within range. + for (i, p) in desc.params.iter().enumerate() { let v = match p.kind { - ParamKind::Scalar { max, .. } => max * 0.5, - ParamKind::Bool => 1.0, + ParamKind::Scalar { min, max, .. } => { + // A fraction that differs per parameter, so no two + // move in lockstep. + let fraction = 0.15 + 0.05 * (i % 4) as f32; + let step = (max - min) * fraction; + if p.default + step <= max { + p.default + step + } else { + p.default - step + } + } + ParamKind::Bool => 1.0 - p.default, }; g.set_param(desc.id, p.id, v); } diff --git a/core/dr-pipeline/src/operation.rs b/core/dr-pipeline/src/operation.rs index 557f299..b0e4bb4 100644 --- a/core/dr-pipeline/src/operation.rs +++ b/core/dr-pipeline/src/operation.rs @@ -22,7 +22,7 @@ use std::fmt::Write as _; -use crate::descriptor::{OpDescriptor, ParamId}; +use crate::descriptor::{OpDescriptor, ParamId, Presentation}; use crate::framing::{Framing, FRAMING_UNIFORM_FIELDS}; /// What an operation's parameters affect, for cache invalidation scoping. @@ -100,6 +100,22 @@ pub trait Operation: Send + Sync { fn helpers(&self) -> &'static [Helper] { &[] } + + /// TRACES: FR-DEV-3a | FR-DEV-3b + /// How this operation would like its parameters presented. + /// + /// `None` — the default, and the right answer for nearly every operation + /// — means one control per parameter, chosen from its + /// [`crate::ParamKind`]. Returning a [`Presentation`] says that several + /// parameters form a single conceptual control and names the widget that + /// draws it. + /// + /// Purely a hint. The parameters remain individually addressable + /// scalars, so a UI that does not implement the named widget falls back + /// to sliders and stays fully functional. + fn presentation(&self) -> Option { + None + } } /// A named WGSL helper function, deduplicated across operations. diff --git a/core/dr-pipeline/src/ops/curve.rs b/core/dr-pipeline/src/ops/curve.rs new file mode 100644 index 0000000..244b650 --- /dev/null +++ b/core/dr-pipeline/src/ops/curve.rs @@ -0,0 +1,645 @@ +//! The tone curve — a monotonic spline through five movable points. +//! +//! The control every other tonal adjustment is a preset of. Highlights, +//! shadows, blacks and whites each shape one region with a fixed weight; the +//! curve lets the photographer put the inflection exactly where the image +//! needs it. +//! +//! # Why the points are ordinary scalars +//! +//! Each point is two [`ParamKind::Scalar`] parameters, x and y. The curve +//! widget is a *presentation* of those scalars (see +//! [`Operation::presentation`]), not a separate kind of value. Three things +//! follow, and all three are why it is built this way: +//! +//! - The parameter API stays `f32`-only, so nothing else in the pipeline, +//! the graph or the sidecar had to change to accommodate a curve. +//! - A UI that has not implemented the curve widget renders ten sliders and +//! remains completely functional. +//! - Undo, clamping and sidecar serialisation work already, because the +//! points are the same kind of thing as every other parameter. +//! +//! The cost is a fixed point count. Adding or removing points at will would +//! need a variable-length value type, which is a much larger change for a +//! control that rarely needs more than five. +//! +//! # Why monotonic +//! +//! A plain cubic spline through user-placed points overshoots: drag one point +//! and the curve can dip *below* its neighbour, which inverts tones locally +//! and shows up as a dark halo in a smooth gradient. The Fritsch-Carlson +//! filter constrains the tangents so the interpolant is monotone wherever the +//! data is, which is exactly the guarantee a tone curve needs. + +use crate::descriptor::{ + LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, Scale, Unit, + WidgetKind, +}; +use crate::operation::{Helper, Operation, Uniform}; +use crate::ops::helpers; + +pub const ID: OpId = OpId("tone_curve"); + +/// How many movable points the curve has. +/// +/// Five: the two endpoints, a mid-tone, and one either side. Enough for the +/// S-curves and shoulder rolls that make up nearly every tonal edit, few +/// enough that the shader can evaluate them without a loop over storage. +pub const POINTS: usize = 5; + +pub const P0_X: ParamId = ParamId("p0_x"); +pub const P0_Y: ParamId = ParamId("p0_y"); +pub const P1_X: ParamId = ParamId("p1_x"); +pub const P1_Y: ParamId = ParamId("p1_y"); +pub const P2_X: ParamId = ParamId("p2_x"); +pub const P2_Y: ParamId = ParamId("p2_y"); +pub const P3_X: ParamId = ParamId("p3_x"); +pub const P3_Y: ParamId = ParamId("p3_y"); +pub const P4_X: ParamId = ParamId("p4_x"); +pub const P4_Y: ParamId = ParamId("p4_y"); + +/// The parameters the curve widget owns, in point order. +static CURVE_PARAMS: [ParamId; POINTS * 2] = + [P0_X, P0_Y, P1_X, P1_Y, P2_X, P2_Y, P3_X, P3_Y, P4_X, P4_Y]; + +/// A coordinate parameter: 0…1 with enough precision to place a point +/// exactly, and a default putting the curve on the identity diagonal. +const fn coord(id: &'static str, label: &'static str, default: f32) -> ParamDescriptor { + ParamDescriptor::scalar( + id, + label, + 0.0, + 1.0, + default, + Unit::None, + Scale::Linear, + // A 4-decimal step is well under a pixel of widget travel, so the + // control never feels quantised. + 4, + ) +} + +static DESCRIPTOR: OpDescriptor = OpDescriptor { + id: ID, + label: LocalizedKey("op.tone_curve"), + // Defaults lie on y = x, so a fresh curve is the identity and the + // operation reports itself inactive. + params: &[ + coord("p0_x", "param.curve.p0_x", 0.0), + coord("p0_y", "param.curve.p0_y", 0.0), + coord("p1_x", "param.curve.p1_x", 0.25), + coord("p1_y", "param.curve.p1_y", 0.25), + coord("p2_x", "param.curve.p2_x", 0.5), + coord("p2_y", "param.curve.p2_y", 0.5), + coord("p3_x", "param.curve.p3_x", 0.75), + coord("p3_y", "param.curve.p3_y", 0.75), + coord("p4_x", "param.curve.p4_x", 1.0), + coord("p4_y", "param.curve.p4_y", 1.0), + ], +}; + +static CURVE_HELPERS: &[Helper] = &[ + helpers::LUMINANCE, + helpers::APPLY_TONE_GAIN, + Helper { + name: "curve_eval", + source: "\ +// Evaluate a monotone cubic Hermite spline through five points. +// +// Fritsch-Carlson (1980): compute secant slopes, take a smooth average for +// the interior tangents, then *limit* each tangent to three times the +// adjoining secant. That limiter is what prevents overshoot — an +// unconstrained spline can dip below a point's neighbour, inverting tones +// and putting a dark halo through a smooth gradient. +// +// Points arrive pre-sorted by x with a minimum separation enforced on the +// CPU, so no division here can be by zero. +fn curve_eval(xs: array, ys: array, x: f32) -> f32 { + // Outside the point range the curve is flat, matching how the endpoints + // read in the widget: nothing exists beyond them to interpolate toward. + if (x <= xs[0]) { return ys[0]; } + if (x >= xs[4]) { return ys[4]; } + + // Locate the span. Five points is few enough that a chain of comparisons + // beats any cleverer search. + var i = 0; + if (x >= xs[3]) { i = 3; } + else if (x >= xs[2]) { i = 2; } + else if (x >= xs[1]) { i = 1; } + + let x0 = xs[i]; + let x1 = xs[i + 1]; + let y0 = ys[i]; + let y1 = ys[i + 1]; + let h = x1 - x0; + let secant = (y1 - y0) / h; + + // Secants either side of each knot, duplicated at the ends so the + // boundary tangents match the adjoining secant. + var s_prev = secant; + if (i > 0) { + s_prev = (ys[i] - ys[i - 1]) / (xs[i] - xs[i - 1]); + } + var s_next = secant; + if (i + 2 <= 4) { + s_next = (ys[i + 2] - ys[i + 1]) / (xs[i + 2] - xs[i + 1]); + } + + // Tangents: the average of adjoining secants, but zero wherever the data + // turns, which is what pins a local extremum in place. + var m0 = 0.5 * (s_prev + secant); + var m1 = 0.5 * (secant + s_next); + if (s_prev * secant <= 0.0) { m0 = 0.0; } + if (secant * s_next <= 0.0) { m1 = 0.0; } + + // A flat span must stay flat. + if (abs(secant) < 0.000001) { + m0 = 0.0; + m1 = 0.0; + } else { + // The Fritsch-Carlson limiter. + let a = m0 / secant; + let b = m1 / secant; + let magnitude = a * a + b * b; + if (magnitude > 9.0) { + let scale = 3.0 / sqrt(magnitude); + m0 = scale * a * secant; + m1 = scale * b * secant; + } + } + + // Cubic Hermite basis on the normalised span. + let t = (x - x0) / h; + let t2 = t * t; + let t3 = t2 * t; + let h00 = 2.0 * t3 - 3.0 * t2 + 1.0; + let h10 = t3 - 2.0 * t2 + t; + let h01 = -2.0 * t3 + 3.0 * t2; + let h11 = t3 - t2; + + return h00 * y0 + h10 * h * m0 + h01 * y1 + h11 * h * m1; +}", + }, +]; + +/// A tone curve through [`POINTS`] movable points. +#[derive(Debug, Clone)] +pub struct ToneCurve { + xs: [f32; POINTS], + ys: [f32; POINTS], +} + +impl Default for ToneCurve { + fn default() -> Self { + // The identity diagonal. + let mut xs = [0.0f32; POINTS]; + let mut ys = [0.0f32; POINTS]; + let mut i = 0; + while i < POINTS { + let t = i as f32 / (POINTS - 1) as f32; + xs[i] = t; + ys[i] = t; + i += 1; + } + Self { xs, ys } + } +} + +impl ToneCurve { + pub fn new() -> Self { + Self::default() + } + + /// Map a parameter id to `(point index, is_y)`. + fn index_of(id: ParamId) -> Option<(usize, bool)> { + let (point, axis) = id.0.split_once('_')?; + let index: usize = point.strip_prefix('p')?.parse().ok()?; + if index >= POINTS { + return None; + } + match axis { + "x" => Some((index, false)), + "y" => Some((index, true)), + _ => None, + } + } + + /// The x coordinates, sorted and separated. + /// + /// The widget cannot reorder points, but a sidecar can carry anything and + /// a spline through unordered or coincident x values divides by zero. + /// Enforced here so the shader never has to check. + fn sorted_xs(&self) -> [f32; POINTS] { + const MIN_GAP: f32 = 0.001; + let mut xs = self.xs; + // Insertion sort: five elements, and it keeps the pairing with ys + // simple to reason about at the call site. + for i in 1..POINTS { + let mut j = i; + while j > 0 && xs[j - 1] > xs[j] { + xs.swap(j - 1, j); + j -= 1; + } + } + // Push apart any coincident pair, left to right. + for i in 1..POINTS { + if xs[i] - xs[i - 1] < MIN_GAP { + xs[i] = xs[i - 1] + MIN_GAP; + } + } + xs + } + + /// Whether the curve differs from the identity. + fn differs_from_identity(&self) -> bool { + self.xs + .iter() + .zip(self.ys.iter()) + .any(|(x, y)| (x - y).abs() > 1e-6) + } +} + +impl Operation for ToneCurve { + fn descriptor(&self) -> &'static OpDescriptor { + &DESCRIPTOR + } + + fn set_param(&mut self, id: ParamId, value: f32) { + match Self::index_of(id) { + Some((i, true)) => self.ys[i] = value, + Some((i, false)) => self.xs[i] = value, + None => log::warn!("tone_curve: unknown parameter {id}"), + } + } + + fn param(&self, id: ParamId) -> f32 { + match Self::index_of(id) { + Some((i, true)) => self.ys[i], + Some((i, false)) => self.xs[i], + None => 0.0, + } + } + + fn is_active(&self) -> bool { + self.differs_from_identity() + } + + fn presentation(&self) -> Option { + Some(Presentation { + widget: WidgetKind::Curve, + params: &CURVE_PARAMS, + }) + } + + fn wgsl_body(&self) -> String { + "\ +let luma = luminance(c); +if (luma > 0.0001) { + // The curve is authored on a display-referred 0..1 axis, which is where + // the eye reads tone and where the widget's grid lives. Scene-referred + // luminance is unbounded, so it is encoded to that axis, curved, and + // decoded back — otherwise a point placed at the middle of the grid + // would not correspond to the middle of the visible range. + let encoded = pow(clamp(luma, 0.0, 1.0), 1.0 / 2.2); + + let xs = array(x0, x1, x2, x3, x4); + let ys = array(y0, y1, y2, y3, y4); + let curved = curve_eval(xs, ys, encoded); + + let decoded = pow(clamp(curved, 0.0, 1.0), 2.2); + // Applied as a ratio so hue is preserved, exactly as contrast does. + c = apply_tone_gain(c, decoded / luma); +} +c = max(c, vec3(0.0));" + .into() + } + + fn uniforms(&self) -> Vec { + let xs = self.sorted_xs(); + vec![ + Uniform { + name: "x0", + value: xs[0], + }, + Uniform { + name: "x1", + value: xs[1], + }, + Uniform { + name: "x2", + value: xs[2], + }, + Uniform { + name: "x3", + value: xs[3], + }, + Uniform { + name: "x4", + value: xs[4], + }, + Uniform { + name: "y0", + value: self.ys[0], + }, + Uniform { + name: "y1", + value: self.ys[1], + }, + Uniform { + name: "y2", + value: self.ys[2], + }, + Uniform { + name: "y3", + value: self.ys[3], + }, + Uniform { + name: "y4", + value: self.ys[4], + }, + ] + } + + fn helpers(&self) -> &'static [Helper] { + CURVE_HELPERS + } +} + +/// Evaluate the curve on the CPU. +/// +/// The same maths as the shader, used by the widget to draw the line it is +/// editing. Duplicating it is deliberate: the alternative is a GPU readback +/// per frame to draw a 200-pixel polyline (ARCH §6.1), and the shared tests +/// below pin the two implementations to the same values. +pub fn evaluate(xs: &[f32; POINTS], ys: &[f32; POINTS], x: f32) -> f32 { + if x <= xs[0] { + return ys[0]; + } + if x >= xs[POINTS - 1] { + return ys[POINTS - 1]; + } + + let mut i = 0; + for k in (1..POINTS - 1).rev() { + if x >= xs[k] { + i = k; + break; + } + } + + let (x0, x1) = (xs[i], xs[i + 1]); + let (y0, y1) = (ys[i], ys[i + 1]); + let h = x1 - x0; + let secant = (y1 - y0) / h; + + let s_prev = if i > 0 { + (ys[i] - ys[i - 1]) / (xs[i] - xs[i - 1]) + } else { + secant + }; + let s_next = if i + 2 <= POINTS - 1 { + (ys[i + 2] - ys[i + 1]) / (xs[i + 2] - xs[i + 1]) + } else { + secant + }; + + let mut m0 = 0.5 * (s_prev + secant); + let mut m1 = 0.5 * (secant + s_next); + if s_prev * secant <= 0.0 { + m0 = 0.0; + } + if secant * s_next <= 0.0 { + m1 = 0.0; + } + + if secant.abs() < 1e-6 { + m0 = 0.0; + m1 = 0.0; + } else { + let a = m0 / secant; + let b = m1 / secant; + let magnitude = a * a + b * b; + if magnitude > 9.0 { + let scale = 3.0 / magnitude.sqrt(); + m0 = scale * a * secant; + m1 = scale * b * secant; + } + } + + let t = (x - x0) / h; + let (t2, t3) = (t * t, t * t * t); + let h00 = 2.0 * t3 - 3.0 * t2 + 1.0; + let h10 = t3 - 2.0 * t2 + t; + let h01 = -2.0 * t3 + 3.0 * t2; + let h11 = t3 - t2; + + h00 * y0 + h10 * h * m0 + h01 * y1 + h11 * h * m1 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn identity() -> ([f32; POINTS], [f32; POINTS]) { + let c = ToneCurve::new(); + (c.xs, c.ys) + } + + #[test] + fn a_fresh_curve_is_the_identity_and_inactive() { + // Opening an unedited image must show the image. + let c = ToneCurve::new(); + assert!(!c.is_active()); + for p in DESCRIPTOR.params { + assert_eq!(c.param(p.id), p.default); + } + } + + #[test] + fn the_identity_curve_returns_its_input() { + let (xs, ys) = identity(); + for i in 0..=20 { + let x = i as f32 / 20.0; + let y = evaluate(&xs, &ys, x); + assert!((y - x).abs() < 1e-4, "identity curve at {x} returned {y}"); + } + } + + #[test] + fn every_parameter_id_maps_to_a_point() { + for p in DESCRIPTOR.params { + assert!( + ToneCurve::index_of(p.id).is_some(), + "{} does not map to a point", + p.id + ); + } + assert_eq!(DESCRIPTOR.params.len(), POINTS * 2); + } + + #[test] + fn moving_a_point_activates_the_curve() { + let mut c = ToneCurve::new(); + c.set_param(P2_Y, 0.65); + assert!(c.is_active()); + assert_eq!(c.param(P2_Y), 0.65); + } + + #[test] + fn the_curve_passes_through_its_control_points() { + // The property that makes the widget honest: the line drawn through + // a point must actually reach it. + let mut c = ToneCurve::new(); + c.set_param(P1_Y, 0.15); + c.set_param(P3_Y, 0.85); + + let xs = c.sorted_xs(); + for i in 0..POINTS { + let y = evaluate(&xs, &c.ys, xs[i]); + assert!( + (y - c.ys[i]).abs() < 1e-4, + "point {i} at x={} evaluated to {y}, expected {}", + xs[i], + c.ys[i] + ); + } + } + + #[test] + fn an_s_curve_stays_monotonic() { + // The reason for Fritsch-Carlson. An unconstrained spline through + // these points overshoots, dipping below a neighbour and inverting + // tones — visible as a dark halo in a smooth gradient. + let mut c = ToneCurve::new(); + c.set_param(P1_Y, 0.10); + c.set_param(P3_Y, 0.90); + + let xs = c.sorted_xs(); + let mut previous = f32::NEG_INFINITY; + for i in 0..=200 { + let x = i as f32 / 200.0; + let y = evaluate(&xs, &c.ys, x); + assert!( + y >= previous - 1e-5, + "curve decreased at x={x}: {y} after {previous}" + ); + previous = y; + } + } + + #[test] + fn an_extreme_curve_stays_monotonic() { + // Every point dragged to a limit — what a user does when exploring + // what a control can do. + let mut c = ToneCurve::new(); + c.set_param(P0_Y, 0.0); + c.set_param(P1_Y, 0.95); + c.set_param(P2_Y, 0.96); + c.set_param(P3_Y, 0.97); + c.set_param(P4_Y, 1.0); + + let xs = c.sorted_xs(); + let mut previous = f32::NEG_INFINITY; + for i in 0..=200 { + let y = evaluate(&xs, &c.ys, i as f32 / 200.0); + assert!(y >= previous - 1e-5, "decreased at {i}"); + assert!(y.is_finite(), "non-finite at {i}"); + previous = y; + } + } + + #[test] + fn a_flat_span_stays_flat() { + // Two points at the same height must not bow between them. + let mut c = ToneCurve::new(); + c.set_param(P1_Y, 0.5); + c.set_param(P2_Y, 0.5); + c.set_param(P3_Y, 0.5); + + let xs = c.sorted_xs(); + for i in 0..=20 { + let x = 0.25 + (i as f32 / 20.0) * 0.5; + let y = evaluate(&xs, &c.ys, x); + assert!((y - 0.5).abs() < 1e-4, "at {x} the flat span gave {y}"); + } + } + + #[test] + fn the_curve_is_clamped_outside_its_endpoints() { + let (xs, ys) = identity(); + assert_eq!(evaluate(&xs, &ys, -1.0), ys[0]); + assert_eq!(evaluate(&xs, &ys, 2.0), ys[POINTS - 1]); + } + + #[test] + fn coincident_x_values_are_separated() { + // A sidecar can carry anything; a spline through two points at the + // same x divides by zero and produces NaN across the image. + let mut c = ToneCurve::new(); + c.set_param(P1_X, 0.5); + c.set_param(P2_X, 0.5); + c.set_param(P3_X, 0.5); + + let xs = c.sorted_xs(); + for i in 1..POINTS { + assert!( + xs[i] > xs[i - 1], + "x values must be strictly increasing, got {xs:?}" + ); + } + // And the result must be usable, not merely non-crashing. + for i in 0..=50 { + assert!(evaluate(&xs, &c.ys, i as f32 / 50.0).is_finite()); + } + } + + #[test] + fn out_of_order_x_values_are_sorted() { + let mut c = ToneCurve::new(); + c.set_param(P1_X, 0.9); + c.set_param(P3_X, 0.1); + let xs = c.sorted_xs(); + for i in 1..POINTS { + assert!(xs[i] > xs[i - 1], "not sorted: {xs:?}"); + } + } + + #[test] + fn the_widget_owns_every_point_parameter() { + // If the presentation misses one, that slider appears twice: once in + // the curve and once as a stray control beneath it. + let presentation = ToneCurve::new().presentation().expect("declares a widget"); + assert_eq!(presentation.widget, WidgetKind::Curve); + assert_eq!(presentation.params.len(), DESCRIPTOR.params.len()); + for p in DESCRIPTOR.params { + assert!( + presentation.params.contains(&p.id), + "{} is not owned by the widget", + p.id + ); + } + } + + #[test] + fn the_fragment_reads_every_declared_uniform() { + let mut c = ToneCurve::new(); + c.set_param(P2_Y, 0.7); + let body = c.wgsl_body(); + for u in c.uniforms() { + assert!( + body.contains(u.name), + "uniform {} is declared but never read", + u.name + ); + } + } + + #[test] + fn unknown_parameters_are_ignored() { + let mut c = ToneCurve::new(); + c.set_param(ParamId("p9_x"), 0.5); + c.set_param(ParamId("nonsense"), 0.5); + c.set_param(ParamId("p1_z"), 0.5); + assert!(!c.is_active()); + } +} diff --git a/core/dr-pipeline/src/ops/mod.rs b/core/dr-pipeline/src/ops/mod.rs index a189d8b..cac5c54 100644 --- a/core/dr-pipeline/src/ops/mod.rs +++ b/core/dr-pipeline/src/ops/mod.rs @@ -15,6 +15,7 @@ pub mod aberration; pub mod colour; pub mod colour_mixer; pub mod contrast; +pub mod curve; pub mod distortion; pub mod exposure; pub mod helpers; @@ -26,6 +27,7 @@ pub use aberration::Aberration; pub use colour::{Brilliance, Saturation, Vibrance}; pub use colour_mixer::ColourMixer; pub use contrast::Contrast; +pub use curve::ToneCurve; pub use distortion::Distortion; pub use exposure::Exposure; pub use tone::{BlacksWhites, HighlightsShadows}; diff --git a/ui/dr-ui/Cargo.toml b/ui/dr-ui/Cargo.toml index 9ee1dbe..69b0f5d 100644 --- a/ui/dr-ui/Cargo.toml +++ b/ui/dr-ui/Cargo.toml @@ -12,7 +12,11 @@ dr-types.workspace = true # must come off when S1 lands (ARCH §6.1, AC-8). dr-gpu = { workspace = true, features = ["readback"] } dr-decode.workspace = true +serde_json.workspace = true +tokio.workspace = true +reqwest.workspace = true dr-plat.workspace = true +dr-sync.workspace = true dr-sync-nextcloud.workspace = true dr-pipeline.workspace = true slint = { workspace = true, features = ["compat-1-2", "renderer-femtovg", "backend-winit"] } diff --git a/ui/dr-ui/src/launch_ui.rs b/ui/dr-ui/src/launch_ui.rs new file mode 100644 index 0000000..2b3396f --- /dev/null +++ b/ui/dr-ui/src/launch_ui.rs @@ -0,0 +1,432 @@ +//! Wires [`LaunchModel`](crate::launch::LaunchModel) to the Slint screen. +//! +//! Kept apart from `lib.rs` so the launch flow can evolve without touching +//! the develop window's wiring. The model holds the state machine and is +//! tested headless; this module only moves values across the boundary. + +use std::cell::RefCell; +use std::rc::Rc; + +use dr_plat::PlatformSecretStore; +use dr_sync::{RemoteBackend, RemotePath}; +use dr_sync_nextcloud::{auth, NextcloudBackend, Session, SessionStore}; + +use slint::ComponentHandle; + +use crate::launch::{LaunchModel, LaunchState}; +use crate::AppWindow; + +/// Shared launch state for the running window. +pub struct LaunchController { + pub model: RefCell, + pub store: SessionStore, + /// Holds any in-flight poll timer. A `Timer` stops when dropped, so it + /// must outlive its own callback — parking it here avoids an Rc cycle + /// between the timer and the closure it runs. + poll_timer: RefCell>, +} + +impl LaunchController { + pub fn new() -> Rc { + let store = SessionStore::open(Box::new(PlatformSecretStore::new())); + let model = LaunchModel::from_store(&store); + Rc::new(Self { + model: RefCell::new(model), + store, + poll_timer: RefCell::new(None), + }) + } + + /// Whether the app should open on the launch screen. + /// + /// Only when there is nothing to show: a configured library goes straight + /// to the images, since making someone click past a login screen they + /// already completed is pure friction. + pub fn should_show(&self, have_local_paths: bool) -> bool { + !have_local_paths && !self.model.borrow().can_open_library() + } +} + +/// Push the model into the window's properties. +pub fn render(window: &AppWindow, controller: &LaunchController) { + let m = controller.model.borrow(); + + window.set_launch_signed_in(m.is_signed_in()); + window.set_launch_account(m.account_label().into()); + window.set_launch_root(m.library_root().into()); + window.set_launch_server(m.server_url.clone().into()); + window.set_launch_busy(m.is_busy()); + window.set_launch_login_url(m.login_url().into()); + window.set_launch_can_remember(m.can_remember); + + let status = match &m.state { + LaunchState::Busy { message, .. } => Some(message.clone()), + _ => m.status.clone(), + }; + window.set_launch_status(status.unwrap_or_default().into()); + window.set_launch_error(m.error.clone().unwrap_or_default().into()); + + let labels: Vec = m + .formats + .iter() + .map(|(f, _)| slint::SharedString::from(f.label())) + .collect(); + let checked: Vec = m.formats.iter().map(|(_, on)| *on).collect(); + window.set_launch_format_labels(slint::ModelRc::new(slint::VecModel::from(labels))); + window.set_launch_format_checked(slint::ModelRc::new(slint::VecModel::from(checked))); +} + +/// Connect the screen's callbacks. +/// +/// `on_open_library` runs when the user opens a configured library, carrying +/// the session so the caller can start a scan. +pub fn wire(window: &AppWindow, controller: Rc, on_open_library: F) +where + F: Fn(Session) + 'static, +{ + // --- sign in ------------------------------------------------------- + { + let weak = window.as_weak(); + let ctl = controller.clone(); + window.on_launch_sign_in(move |server| { + let Some(w) = weak.upgrade() else { return }; + ctl.model.borrow_mut().begin_sign_in(server.to_string()); + render(&w, &ctl); + + let server = ctl.model.borrow().server_url.clone(); + spawn_login(w.as_weak(), ctl.clone(), server); + }); + } + + // --- sign out ------------------------------------------------------ + { + let weak = window.as_weak(); + let ctl = controller.clone(); + window.on_launch_sign_out(move || { + let Some(w) = weak.upgrade() else { return }; + + // Forget locally regardless of whether revocation succeeds — a + // network failure must not leave the credential on this machine. + let session = ctl.model.borrow().session().cloned(); + if let Some(s) = session { + if let Err(e) = ctl.store.forget(&s) { + log::warn!("sign out: {e}"); + } + } + ctl.model.borrow_mut().signed_out(); + render(&w, &ctl); + }); + } + + // --- format tick-boxes --------------------------------------------- + { + let weak = window.as_weak(); + let ctl = controller.clone(); + window.on_launch_format_toggled(move |index, enabled| { + let Some(w) = weak.upgrade() else { return }; + ctl.model.borrow_mut().set_format(index as usize, enabled); + + // Persist immediately, so a choice survives a crash before the + // library is opened. + let (session, filter) = { + let m = ctl.model.borrow(); + (m.session().cloned(), m.format_filter()) + }; + if let Some(mut s) = session { + s.set_format_filter(&filter); + if let Err(e) = ctl.store.update(&s) { + log::warn!("saving format selection: {e}"); + } + } + render(&w, &ctl); + }); + } + + // --- choose folder -------------------------------------------------- + { + let weak = window.as_weak(); + let ctl = controller.clone(); + window.on_launch_choose_folder(move || { + let Some(w) = weak.upgrade() else { return }; + ctl.model.borrow_mut().busy("Listing folders…"); + render(&w, &ctl); + spawn_folder_list(w.as_weak(), ctl.clone()); + }); + } + + // --- open library --------------------------------------------------- + { + let weak = window.as_weak(); + let ctl = controller.clone(); + window.on_launch_open_library(move || { + let Some(w) = weak.upgrade() else { return }; + let session = ctl.model.borrow().session().cloned(); + if let Some(s) = session { + w.set_show_launch(false); + on_open_library(s); + } + }); + } + + // --- copy the login URL --------------------------------------------- + { + let ctl = controller.clone(); + window.on_launch_copy_url(move || { + let url = ctl.model.borrow().login_url(); + if url.is_empty() { + return; + } + // No clipboard dependency yet; logging at least makes the URL + // selectable from a terminal. + log::info!("login url: {url}"); + }); + } + + render(window, &controller); +} + +/// Run Login Flow v2 without blocking the UI thread. +/// +/// Slint's event loop is single-threaded, so the network work happens on a +/// worker and results are posted back with `invoke_from_event_loop`. +fn spawn_login(weak: slint::Weak, ctl: Rc, server: String) { + // The controller is not Send, so it stays here; only plain data crosses + // the thread boundary. + let (tx, rx) = std::sync::mpsc::channel::(); + + std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + let _ = tx.send(LoginMessage::Failed(e.to_string())); + return; + } + }; + + rt.block_on(async { + let client = match dr_sync_nextcloud::http_client("DarkRoom") { + Ok(c) => c, + Err(e) => { + let _ = tx.send(LoginMessage::Failed(e.to_string())); + return; + } + }; + + let flow = match auth::begin(&client, &server, "DarkRoom").await { + Ok(f) => f, + Err(e) => { + let _ = tx.send(LoginMessage::Failed(e.to_string())); + return; + } + }; + + // Open the system browser, never an embedded webview (FR-NC-1). + let _ = open_in_browser(&flow.login_url); + let _ = tx.send(LoginMessage::AwaitingApproval(flow.login_url.clone())); + + match auth::poll(&client, &flow).await { + Ok(creds) => { + let user_id = fetch_user_id(&client, &creds) + .await + .unwrap_or_else(|_| creds.login_name.clone()); + let _ = tx.send(LoginMessage::Success(Box::new((creds, user_id)))); + } + Err(e) => { + let _ = tx.send(LoginMessage::Failed(e.to_string())); + } + } + }); + }); + + poll_channel(weak, ctl, rx); +} + +enum LoginMessage { + AwaitingApproval(String), + Success(Box<(dr_sync_nextcloud::AppCredentials, String)>), + Failed(String), +} + +/// Drain the worker's messages on the UI thread. +fn poll_channel( + weak: slint::Weak, + ctl: Rc, + rx: std::sync::mpsc::Receiver, +) { + let timer = slint::Timer::default(); + let ctl_for_cb = ctl.clone(); + + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(200), + move || { + let Some(w) = weak.upgrade() else { return }; + let ctl = &ctl_for_cb; + while let Ok(msg) = rx.try_recv() { + let mut done = false; + match msg { + LoginMessage::AwaitingApproval(url) => { + ctl.model.borrow_mut().await_approval(url); + } + LoginMessage::Success(boxed) => { + let (creds, user_id) = *boxed; + let session = Session::new(&creds, user_id); + if let Err(e) = ctl.store.save(&session, &creds) { + log::warn!("persisting session: {e}"); + } + ctl.model.borrow_mut().signed_in(session); + done = true; + } + LoginMessage::Failed(e) => { + ctl.model.borrow_mut().fail(e); + done = true; + } + } + render(&w, ctl); + if done { + // Stopping from inside the callback is fine; the timer + // itself is owned by the controller, not this closure. + if let Some(t) = ctl.poll_timer.borrow().as_ref() { + t.stop(); + } + } + } + }, + ); + + *ctl.poll_timer.borrow_mut() = Some(timer); +} + +/// List top-level folders so one can be chosen as the library root. +fn spawn_folder_list(weak: slint::Weak, ctl: Rc) { + let Some(session) = ctl.model.borrow().session().cloned() else { + return; + }; + let creds = match ctl.store.credentials(&session) { + Ok(c) => c, + Err(e) => { + ctl.model.borrow_mut().fail(format!("credentials: {e}")); + if let Some(w) = weak.upgrade() { + render(&w, &ctl); + } + return; + } + }; + + let (tx, rx) = std::sync::mpsc::channel::, String>>(); + let user_id = session.user_id.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + let Ok(rt) = rt else { + let _ = tx.send(Err("runtime".into())); + return; + }; + rt.block_on(async { + match NextcloudBackend::new(&creds, &user_id) { + Ok(b) => match b.list(&RemotePath::root(), None).await { + Ok(entries) => { + let mut dirs: Vec = entries + .iter() + .filter(|e| e.kind == dr_sync::EntryKind::Directory) + .map(|e| e.path.name().to_string()) + .collect(); + dirs.sort_by_key(|d| d.to_ascii_lowercase()); + let _ = tx.send(Ok(dirs)); + } + Err(e) => { + let _ = tx.send(Err(e.to_string())); + } + }, + Err(e) => { + let _ = tx.send(Err(e.to_string())); + } + } + }); + }); + + let timer = slint::Timer::default(); + let ctl_for_cb = ctl.clone(); + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(150), + move || { + let Some(w) = weak.upgrade() else { return }; + let ctl = &ctl_for_cb; + if let Ok(result) = rx.try_recv() { + match result { + Ok(dirs) => { + // No folder picker yet: report what is there and + // leave selection to the CLI, rather than + // pretending to offer a chooser. + let msg = if dirs.is_empty() { + "No folders found.".to_string() + } else { + format!("Folders: {}", dirs.join(", ")) + }; + let session = ctl.model.borrow().session().cloned(); + if let Some(s) = session { + ctl.model.borrow_mut().signed_in(s); + } + ctl.model.borrow_mut().status = Some(msg); + } + Err(e) => ctl.model.borrow_mut().fail(e), + } + render(&w, ctl); + if let Some(t) = ctl.poll_timer.borrow().as_ref() { + t.stop(); + } + } + }, + ); + *ctl.poll_timer.borrow_mut() = Some(timer); +} + +async fn fetch_user_id( + client: &reqwest::Client, + creds: &dr_sync_nextcloud::AppCredentials, +) -> Result { + let url = format!( + "{}/ocs/v2.php/cloud/user?format=json", + creds.server.trim_end_matches('/') + ); + let body = client + .get(&url) + .basic_auth(&creds.login_name, Some(&creds.app_password)) + .header("OCS-APIRequest", "true") + .send() + .await + .map_err(|e| e.to_string())? + .text() + .await + .map_err(|e| e.to_string())?; + + let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?; + v["ocs"]["data"]["id"] + .as_str() + .map(str::to_string) + .ok_or_else(|| "no id in OCS response".into()) +} + +/// Open a URL in the system browser. +fn open_in_browser(url: &str) -> std::io::Result<()> { + #[cfg(target_os = "linux")] + { + std::process::Command::new("xdg-open") + .arg(url) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map(|_| ()) + } + #[cfg(not(target_os = "linux"))] + { + let _ = url; + Ok(()) + } +} diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index de1da00..fb598a4 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -27,6 +27,7 @@ use dr_decode::{Metadata, PreviewSize}; pub use develop::DevelopSession; pub mod launch; +pub mod launch_ui; slint::include_modules!(); @@ -203,6 +204,24 @@ pub fn run(paths: Vec) -> Result<()> { let window = AppWindow::new()?; + // 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 + // a folder goes straight to their images (FR-NC-1). + { + let controller = launch_ui::LaunchController::new(); + let show = controller.should_show(!paths.is_empty()); + window.set_show_launch(show); + launch_ui::wire(&window, controller, |session| { + // Opening a remote library needs the scan-and-cache path, which + // lands with the catalog. Reporting that plainly beats a button + // that silently does nothing. + log::info!("open library requested for {}", session.describe()); + }); + if show { + log::info!("no library configured — showing the launch screen"); + } + } + // The device is shared by demosaic and the adjust pass. Without one the // app still browses through the preview path, just without develop. let gpu = match pollster::block_on(dr_gpu::GpuContext::new_headless()) { diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index 9ec0ea0..a53d173 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -1,5 +1,6 @@ import { Theme } from "theme.slint"; import { AdjustPanel, ParamRow } from "adjust.slint"; +import { LaunchScreen } from "launch.slint"; // Status strip — surfaces the GPU backend and adapter, which matters during // v0.1 because assumption A1 is exactly "does this compositing path work on @@ -137,6 +138,30 @@ export component AppWindow inherits Window { in property total: 0; in property load-error: ""; + // --- launch screen (FR-NC-1, FR-NC-4) --- + // + // The app opens here when no library is configured, and returns here to + // sign out or switch account. + in property show-launch: false; + in property launch-signed-in: false; + in property launch-account: ""; + in property launch-root: ""; + in property launch-server: ""; + in property launch-busy: false; + in property launch-status: ""; + in property launch-error: ""; + in property launch-login-url: ""; + in property launch-can-remember: true; + in-out property <[string]> launch-format-labels; + in-out property <[bool]> launch-format-checked; + + callback launch-sign-in(string); + callback launch-sign-out(); + callback launch-choose-folder(); + callback launch-open-library(); + callback launch-format-toggled(int, bool); + callback launch-copy-url(); + callback next-image(); callback prev-image(); @@ -164,7 +189,32 @@ export component AppWindow inherits Window { // One-way: report width outward, never read layout back into it. changed width => { root.window-resized(self.width); } - VerticalLayout { + // The launch screen replaces the whole window rather than overlaying it: + // there is no library to look at until an account is configured. + if root.show-launch: LaunchScreen { + width: 100%; + height: 100%; + signed-in: root.launch-signed-in; + account: root.launch-account; + library-root: root.launch-root; + server-url: root.launch-server; + busy: root.launch-busy; + status: root.launch-status; + error: root.launch-error; + login-url: root.launch-login-url; + can-remember: root.launch-can-remember; + format-labels: root.launch-format-labels; + format-checked: root.launch-format-checked; + + sign-in(server) => { root.launch-sign-in(server); } + sign-out() => { root.launch-sign-out(); } + choose-folder() => { root.launch-choose-folder(); } + open-library() => { root.launch-open-library(); } + format-toggled(i, on) => { root.launch-format-toggled(i, on); } + copy-login-url() => { root.launch-copy-url(); } + } + + if !root.show-launch: VerticalLayout { StatusBar { adapter: root.adapter; backend: root.backend;