Files
DarkRoom/core/dr-pipeline/src/descriptor.rs
T
dtourolle f630a3ff81 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.
2026-08-09 15:34:46 +02:00

290 lines
9.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Parameter descriptors — operations described as data (ARCH §3.3).
//!
//! The core never builds a control. It publishes what its parameters *are*,
//! and `dr-ui` maps each `ParamKind` to a widget appropriate to the current
//! input modality (ARCH §4.3). Adding an operation therefore needs no UI
//! change (FR-DEV-3c).
//!
//! Labels are keys, not strings: resolving them needs a localiser, and
//! `core/` must not depend on one (NFR-A11Y-1).
use std::fmt;
/// Identifies a parameter within an operation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ParamId(pub &'static str);
impl fmt::Display for ParamId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.0)
}
}
/// Identifies an operation kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct OpId(pub &'static str);
impl fmt::Display for OpId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.0)
}
}
/// A localisation key. The UI resolves it; the core never sees the string.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LocalizedKey(pub &'static str);
/// What a slider's travel means.
///
/// Photographic controls are rarely linear in their underlying quantity:
/// exposure is linear in stops but exponential in light, and a temperature
/// slider that is linear in kelvin feels wrong at both ends.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scale {
Linear,
/// Even *perceptual* steps across the range, for controls whose effect
/// concentrates near one end.
Perceptual,
}
/// The unit a value carries, for display.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Unit {
None,
/// Exposure value — photographers think in stops, not multipliers.
Stops,
Kelvin,
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 {
Scalar {
min: f32,
max: f32,
scale: Scale,
unit: Unit,
precision: u8,
},
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 {
pub id: ParamId,
pub label: LocalizedKey,
pub kind: ParamKind,
pub default: f32,
}
impl ParamDescriptor {
/// A scalar in stops — the exposure-like controls.
pub const fn stops(id: &'static str, label: &'static str, min: f32, max: f32) -> Self {
Self {
id: ParamId(id),
label: LocalizedKey(label),
kind: ParamKind::Scalar {
min,
max,
scale: Scale::Linear,
unit: Unit::Stops,
precision: 2,
},
default: 0.0,
}
}
/// A symmetric −100…+100 control, the familiar shape for tone and colour
/// adjustments. Neutral at zero, so a double-tap reset is meaningful.
pub const fn amount(id: &'static str, label: &'static str) -> Self {
Self {
id: ParamId(id),
label: LocalizedKey(label),
kind: ParamKind::Scalar {
min: -100.0,
max: 100.0,
scale: Scale::Linear,
unit: Unit::None,
precision: 0,
},
default: 0.0,
}
}
/// A toggle. Neutral when off, so the reset contract still holds.
pub const fn switch(id: &'static str, label: &'static str) -> Self {
Self {
id: ParamId(id),
label: LocalizedKey(label),
kind: ParamKind::Bool,
default: 0.0,
}
}
/// A 0…1 fraction — a proportion of something, rather than an amount.
///
/// Its own constructor because the crop rect needs four of them and the
/// default differs per edge: an origin starts at 0 and an extent at 1.
/// Precision of 4 because at 6000px a step of 0.0001 is under a pixel,
/// and a coarser one would make a crop edge unplaceable.
pub const fn fraction(id: &'static str, label: &'static str, default: f32) -> Self {
Self {
id: ParamId(id),
label: LocalizedKey(label),
kind: ParamKind::Scalar {
min: 0.0,
max: 1.0,
scale: Scale::Linear,
unit: Unit::None,
precision: 4,
},
default,
}
}
/// A general scalar with an explicit range and default.
//
// Eight arguments, and a builder would be the usual answer — but this has
// to be `const` so descriptors can be `static`, and `const` functions
// cannot use a builder's method chain. The two common shapes have their
// own constructors above; this is the escape hatch for the rest.
#[allow(clippy::too_many_arguments)]
pub const fn scalar(
id: &'static str,
label: &'static str,
min: f32,
max: f32,
default: f32,
unit: Unit,
scale: Scale,
precision: u8,
) -> Self {
Self {
id: ParamId(id),
label: LocalizedKey(label),
kind: ParamKind::Scalar {
min,
max,
scale,
unit,
precision,
},
default,
}
}
/// Clamp a value into this parameter's declared range.
///
/// Applied before the value reaches a shader: a slider dragged past its
/// bounds, or a sidecar written by a newer version with a wider range,
/// must not produce out-of-range uniforms.
pub fn clamp(&self, value: f32) -> f32 {
match self.kind {
ParamKind::Scalar { min, max, .. } => {
if value.is_finite() {
value.clamp(min, max)
} else {
// A NaN from a corrupt sidecar would otherwise poison the
// uniform block and blank the image.
self.default
}
}
ParamKind::Bool => {
if value != 0.0 {
1.0
} else {
0.0
}
}
}
}
}
/// The static description of an operation.
#[derive(Debug, Clone, PartialEq)]
pub struct OpDescriptor {
pub id: OpId,
pub label: LocalizedKey,
pub params: &'static [ParamDescriptor],
}
impl OpDescriptor {
pub fn param(&self, id: ParamId) -> Option<&ParamDescriptor> {
self.params.iter().find(|p| p.id == id)
}
}
#[cfg(test)]
mod tests {
use super::*;
const P: ParamDescriptor = ParamDescriptor::amount("test", "test.label");
#[test]
fn values_clamp_into_range() {
assert_eq!(P.clamp(150.0), 100.0);
assert_eq!(P.clamp(-150.0), -100.0);
assert_eq!(P.clamp(42.0), 42.0);
}
#[test]
fn a_nan_falls_back_to_the_default_rather_than_poisoning_the_uniform() {
// A corrupt sidecar must not blank the image: one NaN in a uniform
// block propagates through every pixel.
assert_eq!(P.clamp(f32::NAN), P.default);
assert_eq!(P.clamp(f32::INFINITY), P.default);
}
#[test]
fn amount_controls_are_neutral_at_zero() {
// Double-tap-to-reset and "is this op doing anything" both depend on
// neutral being zero.
assert_eq!(P.default, 0.0);
}
}