Wire the launch screen into the app

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.
This commit is contained in:
2026-08-09 15:34:46 +02:00
parent 09e3043f4c
commit f630a3ff81
11 changed files with 1255 additions and 8 deletions
Generated
+4
View File
@@ -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",
]
+43
View File
@@ -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 {
+16 -1
View File
@@ -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<ParamCapability>,
/// 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<Presentation>,
}
/// 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()
+22 -5
View File
@@ -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);
}
+17 -1
View File
@@ -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<Presentation> {
None
}
}
/// A named WGSL helper function, deduplicated across operations.
+645
View File
@@ -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<f32, 5>, ys: array<f32, 5>, 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<Presentation> {
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<f32, 5>(x0, x1, x2, x3, x4);
let ys = array<f32, 5>(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<f32>(0.0));"
.into()
}
fn uniforms(&self) -> Vec<Uniform> {
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());
}
}
+2
View File
@@ -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};
+4
View File
@@ -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"] }
+432
View File
@@ -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<LaunchModel>,
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<Option<slint::Timer>>,
}
impl LaunchController {
pub fn new() -> Rc<Self> {
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<slint::SharedString> = m
.formats
.iter()
.map(|(f, _)| slint::SharedString::from(f.label()))
.collect();
let checked: Vec<bool> = 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<F>(window: &AppWindow, controller: Rc<LaunchController>, 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<AppWindow>, ctl: Rc<LaunchController>, 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::<LoginMessage>();
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<AppWindow>,
ctl: Rc<LaunchController>,
rx: std::sync::mpsc::Receiver<LoginMessage>,
) {
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<AppWindow>, ctl: Rc<LaunchController>) {
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::<Result<Vec<String>, 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<String> = 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<String, String> {
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(())
}
}
+19
View File
@@ -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<PathBuf>) -> 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()) {
+51 -1
View File
@@ -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 <int> total: 0;
in property <string> 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 <bool> show-launch: false;
in property <bool> launch-signed-in: false;
in property <string> launch-account: "";
in property <string> launch-root: "";
in property <string> launch-server: "";
in property <bool> launch-busy: false;
in property <string> launch-status: "";
in property <string> launch-error: "";
in property <string> launch-login-url: "";
in property <bool> 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;