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.
290 lines
9.2 KiB
Rust
290 lines
9.2 KiB
Rust
//! 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);
|
||
}
|
||
}
|