Give the controls a vocabulary, and let a node ask for one

widgets.slint set the rule — screens consume components, and a bare `Theme.*`
at a call site means a component is missing — and it set it for chrome only.
The controls never got the same treatment, so they were written wherever they
were first needed and copied from there.

**The slider was private to the develop panel.** `SliderTrack`, with the
fifty-line preamble explaining how it wrests a drag away from a Flickable,
lived inside adjust.slint and no other screen could reach it. It shows: export
quality is a 1-to-100 value, and the settings page offered a free-text box for
it, with the range written in a hint and enforced nowhere. `to-float()` answers
0 for anything it cannot parse, so a typo saved a quality of 0 and the page
displayed the 0 back as though it had been asked for.

The tick-box was written twice, in launch.slint and settings.slint, from the
same 18px box and the same handler; the second carried a comment deferring the
lift until a third caller appeared. The label-and-hint header was written three
times inside settings.slint alone.

controls.slint is the input layer beside widgets.slint's chrome layer, and the
constraint that makes it reusable is that **nothing in it knows about
`ParamRow`** — that struct is the develop panel's flattening of the capability
model, and a control that imported it could only ever be used by the develop
panel. The primitives take plain numbers; the ParamRow-shaped wrappers stay in
the panel that owns the model. 658 lines came out of the three screens.

`SliderRow` is the slider-plus-number-box ARCH §4.3 names as the pointer
presentation of a bounded scalar, and quality is its first adopter. It commits
on gesture end rather than on every movement, because the settings page saves
to disk on change and a two-second drag is a couple of hundred writes where a
text field committed once. The develop panel keeps the live stream — that is
what its pipeline is for — so `SliderTrack` now reports both.

**The other half is the descriptor.** FR-DEV-3a and ARCH §4.3a already specify
more than was built: an ordered preference list of widgets rather than one, the
demands a widget makes, and kinds beyond scalar and bool.

- `Presentation.widgets` is now a list, walked by `choose`, falling back to
  plain sliders. Falling off the end is not an error, and there is a test
  asserting an operation asking only for an unimplemented widget still yields
  one control per parameter.
- `WidgetDemand` carries what a widget inherently needs — two-dimensional
  dragging, precise pointing — and no pixels, breakpoints or platform names.
- `WidgetKind` grows to the specified set. There is deliberately no `Colour`
  *kind*: a colour is three numbers, and a value type that is not an `f32`
  would reach through the graph, the uniform block and the sidecar format to
  buy what `ColourWheel` over three scalars already describes. Every widget
  here is a hint over ordinary scalars, which is what keeps the fallback
  honest.
- `ParamKind::Enum` is the one new shape, and it fits because a variant index
  is exact in binary32. `kind: enum` with a `variants:` list works in
  `ops/*.yaml`, so a node declaring one gets a segmented control with no UI
  file edited — which is the promise ops/mod.rs already makes.

The panel's dispatch was duplicated: a lone parameter and a grouped one each
wrote out their own list of kinds, so `enum` would have had to be added twice
and a kind added to one would appear or vanish depending on how many parameters
its operation happened to declare. `ParamControl` is now the only such chain.

`rows_from` is free-standing rather than a method, which is what lets the
FR-DEV-3c acceptance test requirements.md asks for actually be written: an
operation the frontend has never heard of, appearing in a generated panel, with
no GPU in sight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 23:21:35 +02:00
co-authored by Claude Opus 5
parent e7130ff891
commit 0a331c717e
10 changed files with 1662 additions and 910 deletions
+43 -1
View File
@@ -586,9 +586,51 @@ fn param_ctor(
max, max,
) )
} }
// A fixed list of named alternatives. The value is the chosen index,
// so the range is the list's own bounds and a node declaring one needs
// no `min:`/`max:` of its own.
//
// Variants are localisation keys, like every other label in a node —
// the core never holds a display string (NFR-A11Y-1). The default is
// always the first, so `reset` means the same thing here as everywhere
// else; a node whose neutral choice is not first has listed them in
// the wrong order.
"enum" => {
let variants = spec
.get("variants")
.and_then(Value::as_sequence)
.ok_or_else(|| format!("`{ctx}` is `kind: enum` and needs a `variants:` list"))?;
if variants.len() < 2 {
return Err(format!(
"`{ctx}.variants` lists {} choice(s); a control the user \
cannot change is not a control",
variants.len()
));
}
let keys = variants
.iter()
.enumerate()
.map(|(i, v)| {
as_str(v, &format!("{ctx}.variants[{i}]"))
.map(|k| format!("LocalizedKey({k:?})"))
})
.collect::<Result<Vec<_>, _>>()?;
(
format!(
"ParamDescriptor::choice({:?}, {:?}, &[{}])",
id,
label,
keys.join(", ")
),
0.0,
0.0,
(variants.len() - 1) as f64,
)
}
other => { other => {
return Err(format!( return Err(format!(
"`{ctx}.kind` is `{other}`; expected stops, amount, switch, fraction or scalar" "`{ctx}.kind` is `{other}`; expected stops, amount, switch, \
fraction, scalar or enum"
)) ))
} }
}) })
+161 -12
View File
@@ -59,23 +59,91 @@ pub enum Unit {
/// A control that does not reduce to a slider or a switch. /// 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 /// The core names the *kind* of widget; the frontend owns what it looks like
/// and how it behaves (ARCH §3.3). This is deliberately a small closed /// and how it behaves (ARCH §3.3). This is deliberately a small closed enum
/// enum rather than an open string: a UI must be able to match exhaustively /// rather than an open string: a UI must be able to match exhaustively and
/// and know it has covered everything the core can ask for. /// know it has covered everything the core can ask for.
///
/// **Every one of these is a hint over ordinary scalar parameters**, never a
/// new kind of value. A curve is its point coordinates, a colour wheel is
/// three numbers, a crop is four edges — all [`ParamKind::Scalar`], all
/// individually addressable, all persisted by the sidecar with no special
/// case. That is what makes the fallback in [`Presentation::widgets`] honest:
/// a frontend that implements none of these still renders every parameter as
/// a slider and the edit works, merely more tediously.
///
/// It is also why there is no `Colour` *kind*. A colour is three or four
/// numbers, and introducing a value type that is not an `f32` would reach
/// through the graph, the uniform block and the sidecar format to buy a
/// control that [`ColourWheel`](WidgetKind::ColourWheel) already describes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WidgetKind { pub enum WidgetKind {
/// A tone curve, edited by dragging points on a grid. /// A tone curve, edited by dragging points on a grid.
/// ///
/// The underlying parameters are ordinary [`ParamKind::Scalar`]s — the /// The underlying parameters are the point coordinates, x and y
/// point coordinates — so a UI that does not implement the curve widget /// interleaved.
/// can still present them as sliders and remain fully functional. That ToneCurve,
/// fallback is the reason the points are scalars rather than an opaque /// Colour grading wheels: a hue-and-strength pad per tonal range.
/// blob. ColourWheel,
Curve, /// On-canvas crop and straighten handles. Parameters are the four crop
/// edges as fractions, and the straighten angle.
CropOverlay,
/// On-canvas placement of a linear or radial mask.
GradientHandle,
/// On-canvas brush strokes.
BrushMask,
/// An eyedropper bound to the canvas, setting white balance from a pixel.
WhitePoint,
}
impl WidgetKind {
/// TRACES: FR-UI-7
/// Whether this widget is manipulated on the photograph rather than in a
/// panel.
///
/// A property of the widget itself, not of the screen: a crop is dragged
/// on the image wherever the image is, and that is true of a phone and a
/// workstation alike. Where the panel then *puts* the affordance that
/// turns it on, and how large its handles are, stay frontend decisions
/// (ARCH §4.3a).
pub fn is_on_canvas(self) -> bool {
matches!(
self,
Self::CropOverlay | Self::GradientHandle | Self::BrushMask | Self::WhitePoint
)
}
}
/// TRACES: FR-DEV-3a
/// What a widget inherently needs in order to be usable.
///
/// **Demands describe the control, not the screen** (ARCH §4.3a). A curve
/// needs two-dimensional pointing and a certain amount of room to be worth
/// drawing at all; those are facts about curves. Whether *this* window has
/// that room, at what breakpoint, on what platform, is the frontend's
/// question, and a demand carrying pixels or a platform name would be the core
/// answering it — a core that reasons about pixels will eventually be wrong
/// about a display it never saw.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct WidgetDemand {
/// Needs the value to be dragged in two dimensions at once. A frontend
/// with only a keyboard, or a strictly linear input, should skip it.
pub two_dimensional: bool,
/// Needs pointing accurate to a small fraction of the control. A frontend
/// driving a television with a remote should skip it; touch is fine, since
/// hit regions grow to the modality (FR-UI-7).
pub precise_pointing: bool,
} }
/// The shape of a parameter's value. /// The shape of a parameter's value.
///
/// **Every variant is carried as an `f32`.** That is not an implementation
/// detail to be tidied away later — it is what lets one storage path serve
/// every parameter: the sidecar writes a number, the uniform block takes a
/// number, and `set_param` is one function rather than one per shape. A
/// variant that needed a richer value would reach through all three, which is
/// why a colour is a [`WidgetKind::ColourWheel`] over three scalars rather
/// than a kind of its own.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum ParamKind { pub enum ParamKind {
Scalar { Scalar {
@@ -86,6 +154,24 @@ pub enum ParamKind {
precision: u8, precision: u8,
}, },
Bool, Bool,
/// TRACES: FR-DEV-3a
/// One of a short, fixed list of named alternatives.
///
/// The value is the chosen variant's **index**, held as an `f32` like
/// everything else — small integers are exact in binary32, so this costs
/// nothing in fidelity and keeps the parameter on the ordinary storage
/// path.
///
/// Distinct from a `Scalar` running 0..n because the numbers are not on a
/// scale: interpolating between two of them is meaningless, dragging
/// through them is not a gesture anyone wants, and the labels are the
/// whole point. A UI that treated this as a scalar would render a slider
/// reading "2" where the user needs to see "Bicubic".
Enum {
/// In index order. The label is a localisation key, resolved by the
/// frontend — `core/` must not depend on a localiser (NFR-A11Y-1).
variants: &'static [LocalizedKey],
},
} }
/// TRACES: FR-DEV-3a | FR-DEV-3b /// TRACES: FR-DEV-3a | FR-DEV-3b
@@ -105,14 +191,44 @@ pub enum ParamKind {
/// operations, which want plain sliders, say nothing at all. /// operations, which want plain sliders, say nothing at all.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct Presentation { pub struct Presentation {
pub widget: WidgetKind, /// Widgets that would draw these parameters, **in descending order of
/// The parameters this widget owns, in the order it expects them. /// preference** (ARCH §4.3a).
///
/// The frontend walks the list and takes the first it both implements and
/// can afford. Falling off the end is not an error: every parameter
/// remains an individually addressable scalar, so plain sliders are always
/// the final fallback and the edit still works.
///
/// A list rather than one kind because the alternatives are real. A colour
/// grading operation is best as a wheel, acceptable as a hue-and-strength
/// pair of sliders, and an operation that can say so gets a good control
/// on a workstation and a usable one on a phone without the core knowing
/// which it is talking to.
pub widgets: &'static [WidgetKind],
/// What the preferred widget needs in order to be worth drawing.
///
/// Applies to the list as a whole rather than per entry: a frontend that
/// cannot meet the demand skips to plain sliders, which is the same answer
/// it gives for a widget it has not implemented.
pub demand: WidgetDemand,
/// The parameters these widgets own, in the order they expect them.
/// ///
/// Parameters absent from this list are presented normally, so an /// Parameters absent from this list are presented normally, so an
/// operation can pair a curve with an ordinary strength slider. /// operation can pair a curve with an ordinary strength slider.
pub params: &'static [ParamId], pub params: &'static [ParamId],
} }
impl Presentation {
/// The first widget in the preference list that `supported` accepts.
///
/// The walk lives here rather than in each frontend so that "first
/// supported, else sliders" is written once and cannot drift between a
/// desktop UI, a test harness and whatever consumes capabilities next.
pub fn choose(&self, supported: impl Fn(WidgetKind) -> bool) -> Option<WidgetKind> {
self.widgets.iter().copied().find(|w| supported(*w))
}
}
/// TRACES: FR-DEV-3a /// TRACES: FR-DEV-3a
/// A parameter's place in an operation whose parameters form a grid. /// A parameter's place in an operation whose parameters form a grid.
/// ///
@@ -206,6 +322,26 @@ impl ParamDescriptor {
} }
} }
/// One of a fixed list of alternatives, defaulting to the first.
///
/// The first rather than a caller-chosen index, so the reset contract
/// holds the way it does for every other kind: index 0 is the neutral
/// choice, and an operation whose default is not its first variant has
/// listed them in the wrong order.
pub const fn choice(
id: &'static str,
label: &'static str,
variants: &'static [LocalizedKey],
) -> Self {
Self {
id: ParamId(id),
label: LocalizedKey(label),
kind: ParamKind::Enum { variants },
default: 0.0,
facet: None,
}
}
/// A 0…1 fraction — a proportion of something, rather than an amount. /// 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 /// Its own constructor because the crop rect needs four of them and the
@@ -299,6 +435,19 @@ impl ParamDescriptor {
0.0 0.0
} }
} }
// Rounded before clamping, because the value arriving here is an
// `f32` that has been through a sidecar and possibly a slider: an
// index of 1.9999 is variant 2, and truncating it to 1 would
// silently select the wrong option. A non-finite index falls back
// to the default for the same reason a scalar does.
ParamKind::Enum { variants } => {
if value.is_finite() {
let last = variants.len().saturating_sub(1) as f32;
value.round().clamp(0.0, last)
} else {
self.default
}
}
} }
} }
} }
+25
View File
@@ -447,6 +447,24 @@ mod tests {
); );
} }
ParamKind::Bool => {} ParamKind::Bool => {}
ParamKind::Enum { variants } => {
// An empty list is a control with nothing to pick, and
// a one-entry list is a control that cannot be
// changed — both are declaration mistakes rather than
// states a UI should try to render.
assert!(
variants.len() > 1,
"{}.{} offers fewer than two choices",
cap.id,
p.id
);
assert!(
p.default >= 0.0 && p.default < variants.len() as f32,
"{}.{} defaults to a variant that does not exist",
cap.id,
p.id
);
}
} }
} }
} }
@@ -536,6 +554,13 @@ mod tests {
c.label.0, p.label.0, p.value c.label.0, p.label.0, p.value
), ),
ParamKind::Bool => format!("{}/{}: switch", c.label.0, p.label.0), ParamKind::Bool => format!("{}/{}: switch", c.label.0, p.label.0),
ParamKind::Enum { variants } => format!(
"{}/{}: choice of {} = {}",
c.label.0,
p.label.0,
variants.len(),
p.value
),
}) })
}) })
.collect(); .collect();
+6 -1
View File
@@ -41,7 +41,7 @@ pub mod sidecar;
pub use descriptor::{ pub use descriptor::{
Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Presentation, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Presentation,
Scale, Unit, WidgetKind, Scale, Unit, WidgetDemand, WidgetKind,
}; };
pub use framing::{CropRect, Framing}; pub use framing::{CropRect, Framing};
pub use graph::{EditGraph, OpCapability, ParamCapability}; pub use graph::{EditGraph, OpCapability, ParamCapability};
@@ -81,6 +81,11 @@ mod tests {
} }
} }
ParamKind::Bool => 1.0 - p.default, ParamKind::Bool => 1.0 - p.default,
// The last variant, so the choice differs from the
// default whatever the list holds. A single-variant enum
// cannot be moved off its default and is correctly left
// where it is.
ParamKind::Enum { variants } => variants.len().saturating_sub(1) as f32,
}; };
g.set_param(desc.id, p.id, v); g.set_param(desc.id, p.id, v);
} }
+19 -3
View File
@@ -33,7 +33,7 @@
use crate::descriptor::{ use crate::descriptor::{
LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, Scale, Unit, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, Scale, Unit,
WidgetKind, WidgetDemand, WidgetKind,
}; };
use crate::operation::{Helper, Operation, Uniform}; use crate::operation::{Helper, Operation, Uniform};
use crate::ops::helpers; use crate::ops::helpers;
@@ -293,7 +293,16 @@ impl Operation for ToneCurve {
fn presentation(&self) -> Option<Presentation> { fn presentation(&self) -> Option<Presentation> {
Some(Presentation { Some(Presentation {
widget: WidgetKind::Curve, // One entry: there is no second way to draw a tone curve that is
// better than the sliders the frontend falls back to anyway.
widgets: &[WidgetKind::ToneCurve],
demand: WidgetDemand {
// A point is dragged in x and y together — that is what a
// curve *is*, and a frontend that can only move one axis at a
// time is better off with the point coordinates as sliders.
two_dimensional: true,
precise_pointing: true,
},
params: &CURVE_PARAMS, params: &CURVE_PARAMS,
}) })
} }
@@ -614,7 +623,14 @@ mod tests {
// If the presentation misses one, that slider appears twice: once in // If the presentation misses one, that slider appears twice: once in
// the curve and once as a stray control beneath it. // the curve and once as a stray control beneath it.
let presentation = ToneCurve::new().presentation().expect("declares a widget"); let presentation = ToneCurve::new().presentation().expect("declares a widget");
assert_eq!(presentation.widget, WidgetKind::Curve); assert_eq!(presentation.widgets, &[WidgetKind::ToneCurve]);
// A frontend that implements the curve gets it; one that implements
// nothing falls through to sliders rather than to an error.
assert_eq!(
presentation.choose(|w| w == WidgetKind::ToneCurve),
Some(WidgetKind::ToneCurve)
);
assert_eq!(presentation.choose(|_| false), None);
assert_eq!(presentation.params.len(), DESCRIPTOR.params.len()); assert_eq!(presentation.params.len(), DESCRIPTOR.params.len());
for p in DESCRIPTOR.params { for p in DESCRIPTOR.params {
assert!( assert!(
+209 -11
View File
@@ -84,8 +84,40 @@ impl DevelopSession {
/// Built entirely from the capability list. The `kind` string chooses the /// Built entirely from the capability list. The `kind` string chooses the
/// widget; nothing switches on a parameter's identity. /// widget; nothing switches on a parameter's identity.
pub fn rows(&self) -> Vec<ParamRow> { pub fn rows(&self) -> Vec<ParamRow> {
rows_from(&self.graph.capabilities())
}
}
/// Whether this frontend has an implementation of `widget`.
///
/// The single source of truth for what the panel can draw. A function rather
/// than a constant list so that a kind whose availability depends on something
/// — a canvas-hosted widget needs a canvas — has one obvious place to say so.
pub(crate) fn draws(widget: WidgetKind) -> bool {
match widget {
WidgetKind::ToneCurve => true,
// Not implemented here. The `is_on_canvas` kinds additionally need a
// host the panel does not have: the canvas draws those, and the
// panel's job is the affordance that turns them on.
WidgetKind::ColourWheel
| WidgetKind::CropOverlay
| WidgetKind::GradientHandle
| WidgetKind::BrushMask
| WidgetKind::WhitePoint => false,
}
}
/// The panel model for a set of capabilities.
///
/// Free-standing rather than a method, and that is the point: it needs no GPU,
/// no decoded image and no session, so the whole descriptor-to-panel path can
/// be exercised against a hand-built capability list. That is what the
/// FR-DEV-3c acceptance test asks for — an operation the frontend has never
/// heard of appearing in a generated panel — and it cannot be asserted at all
/// if generating a row requires a device.
pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec<ParamRow> {
let mut rows = Vec::new(); let mut rows = Vec::new();
for (op_index, op) in self.graph.capabilities().iter().enumerate() { for (op_index, op) in caps.iter().enumerate() {
// Framing has a panel of its own. // Framing has a panel of its own.
// //
// The one place this side names a stage, and the exception proves // The one place this side names a stage, and the exception proves
@@ -107,12 +139,30 @@ impl DevelopSession {
// renders the same parameters as ordinary sliders, and the edit // renders the same parameters as ordinary sliders, and the edit
// still works — which is exactly why the hint is a hint. // still works — which is exactly why the hint is a hint.
if let Some(presentation) = &op.presentation { if let Some(presentation) = &op.presentation {
// A `match` rather than an `if let`: when a second widget // **The widget registry, and the only one.**
// kind is added, this stops compiling until it is handled, //
// rather than silently falling through to sliders. // `choose` walks the operation's preference list and hands
let row = match presentation.widget { // back the first entry this frontend implements (ARCH §4.3a).
WidgetKind::Curve => self.curve_row(op_index, group_head, op, presentation), // Everything not listed here is unimplemented by definition,
}; // so an operation asking for a colour wheel gets sliders
// rather than an error — which is the designed behaviour, not
// a gap: every parameter is an individually addressable
// scalar, so the edit still works.
//
// The `match` inside is exhaustive on purpose. Adding a
// `WidgetKind` to the core stops this compiling until someone
// has decided, here, whether this frontend draws it.
let row = presentation.choose(draws).and_then(|widget| match widget {
WidgetKind::ToneCurve => curve_row(op_index, group_head, op, presentation),
// Named but not drawn here. Listed rather than caught
// by a wildcard so that the next kind added to the
// core surfaces as a compile error in this file.
WidgetKind::ColourWheel
| WidgetKind::CropOverlay
| WidgetKind::GradientHandle
| WidgetKind::BrushMask
| WidgetKind::WhitePoint => None,
});
if let Some(row) = row { if let Some(row) = row {
rows.push(row); rows.push(row);
continue; continue;
@@ -137,6 +187,9 @@ impl DevelopSession {
for param_index in presentation_order(&op.params) { for param_index in presentation_order(&op.params) {
let p = &op.params[param_index]; let p = &op.params[param_index];
// Empty for every kind but `Enum`, which is what the panel
// keys on to build a segmented control rather than a slider.
let mut choices: Vec<slint::SharedString> = Vec::new();
let (kind, min, max, precision, unit) = match &p.kind { let (kind, min, max, precision, unit) = match &p.kind {
ParamKind::Scalar { ParamKind::Scalar {
min, min,
@@ -152,6 +205,17 @@ impl DevelopSession {
unit_suffix(*unit), unit_suffix(*unit),
), ),
ParamKind::Bool => ("bool", 0.0, 1.0, 0, ""), ParamKind::Bool => ("bool", 0.0, 1.0, 0, ""),
// The value is a variant index, so the range is the list's
// own bounds and the precision is whole numbers. Labels are
// resolved here, against this crate's catalogue, because
// the core deals in localisation keys only (NFR-A11Y-1).
ParamKind::Enum { variants } => {
choices = variants
.iter()
.map(|v| labels::resolve(v.0).into())
.collect();
("enum", 0.0, variants.len().saturating_sub(1) as f32, 0, "")
}
}; };
// A faceted parameter is named by its *subject* — the band — // A faceted parameter is named by its *subject* — the band —
@@ -189,6 +253,7 @@ impl DevelopSession {
unit: unit.into(), unit: unit.into(),
// Only curve rows carry points. // Only curve rows carry points.
points: slint::ModelRc::new(slint::VecModel::from(Vec::<f32>::new())), points: slint::ModelRc::new(slint::VecModel::from(Vec::<f32>::new())),
choices: slint::ModelRc::new(slint::VecModel::from(choices)),
}); });
} }
} }
@@ -198,10 +263,9 @@ impl DevelopSession {
/// One row standing for a whole curve. /// One row standing for a whole curve.
/// ///
/// Returns `None` if the operation's parameters do not look like point /// Returns `None` if the operation's parameters do not look like point
/// coordinates, in which case the caller falls back to sliders rather /// coordinates, in which case the caller falls back to sliders rather than
/// than rendering a broken widget. /// rendering a broken widget.
fn curve_row( fn curve_row(
&self,
op_index: usize, op_index: usize,
group_head: usize, group_head: usize,
op: &OpCapability, op: &OpCapability,
@@ -258,9 +322,12 @@ impl DevelopSession {
precision: 4, precision: 4,
unit: String::new().into(), unit: String::new().into(),
points: slint::ModelRc::new(slint::VecModel::from(points)), points: slint::ModelRc::new(slint::VecModel::from(points)),
// A curve is not a choice between named alternatives.
choices: slint::ModelRc::new(slint::VecModel::from(Vec::<slint::SharedString>::new())),
}) })
} }
impl DevelopSession {
/// The curve's shape, sampled for drawing. /// The curve's shape, sampled for drawing.
/// ///
/// Evaluated with `dr_pipeline`'s own spline, so the line the user drags /// Evaluated with `dr_pipeline`'s own spline, so the line the user drags
@@ -1091,6 +1158,135 @@ mod tests {
/// A widget hint only collapses an operation to one row when it is /// A widget hint only collapses an operation to one row when it is
/// *honoured*; `rows` falls back to sliders otherwise, and mirroring that /// *honoured*; `rows` falls back to sliders otherwise, and mirroring that
/// here is what keeps the test honest when a hint stops applying. /// here is what keeps the test honest when a hint stops applying.
/// TRACES: FR-DEV-3c
/// An operation this file has never heard of, appearing in the panel.
///
/// The acceptance test requirements.md names for FR-DEV-3c: "a test
/// operation added to the registry appears in a generated panel with no
/// frontend change". Built as a capability rather than a real node so it
/// costs the pipeline nothing — what is being asserted is the mapping from
/// descriptor to control, and that mapping does not care whether a shader
/// exists behind it.
#[test]
fn an_operation_the_frontend_has_never_heard_of_gets_controls() {
use dr_pipeline::{LocalizedKey, ParamCapability};
let invented = OpCapability {
id: OpId("invented"),
label: LocalizedKey("op.invented"),
active: false,
presentation: None,
params: vec![
ParamCapability {
id: ParamId("strength"),
label: LocalizedKey("param.invented.strength"),
kind: ParamKind::Scalar {
min: -100.0,
max: 100.0,
scale: dr_pipeline::Scale::Linear,
unit: Unit::Percent,
precision: 0,
},
default: 0.0,
value: 25.0,
facet: None,
},
ParamCapability {
id: ParamId("method"),
label: LocalizedKey("param.invented.method"),
kind: ParamKind::Enum {
variants: &[
LocalizedKey("param.invented.method.fast"),
LocalizedKey("param.invented.method.exact"),
],
},
default: 0.0,
value: 1.0,
facet: None,
},
],
};
let rows = rows_from(&[invented]);
assert_eq!(rows.len(), 2, "each parameter should become one row");
// The scalar becomes a slider carrying its declared range and unit.
assert_eq!(rows[0].kind, "scalar");
assert_eq!(rows[0].minimum, -100.0);
assert_eq!(rows[0].maximum, 100.0);
assert_eq!(rows[0].value, 25.0);
// The enum becomes a choice, with its range spanning the variant
// indices and the variant names resolved for drawing. Nothing in this
// file names the operation or either parameter to make that happen.
assert_eq!(rows[1].kind, "enum");
assert_eq!(rows[1].minimum, 0.0);
assert_eq!(rows[1].maximum, 1.0);
assert_eq!(rows[1].precision, 0);
assert_eq!(slint::Model::row_count(&rows[1].choices), 2);
// The value is the selected index, which is what the segmented control
// reads — an enum needs no separate selection field.
assert_eq!(rows[1].value, 1.0);
}
#[test]
fn an_unimplemented_widget_falls_back_to_sliders_rather_than_vanishing() {
// ARCH §4.3a: falling off the end of the preference list is not an
// error. An operation asking only for a widget this frontend does not
// draw must still yield one control per parameter, or declaring a
// preference would be a way to make an edit unreachable.
use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand};
let wheel = OpCapability {
id: OpId("grading"),
label: LocalizedKey("op.grading"),
active: false,
presentation: Some(Presentation {
widgets: &[WidgetKind::ColourWheel],
demand: WidgetDemand {
two_dimensional: true,
precise_pointing: false,
},
params: &[ParamId("hue"), ParamId("strength")],
}),
params: vec![
ParamCapability {
id: ParamId("hue"),
label: LocalizedKey("param.grading.hue"),
kind: ParamKind::Scalar {
min: 0.0,
max: 360.0,
scale: dr_pipeline::Scale::Linear,
unit: Unit::None,
precision: 0,
},
default: 0.0,
value: 0.0,
facet: None,
},
ParamCapability {
id: ParamId("strength"),
label: LocalizedKey("param.grading.strength"),
kind: ParamKind::Scalar {
min: 0.0,
max: 1.0,
scale: dr_pipeline::Scale::Linear,
unit: Unit::None,
precision: 2,
},
default: 0.0,
value: 0.0,
facet: None,
},
],
};
assert!(!draws(WidgetKind::ColourWheel), "precondition");
let rows = rows_from(&[wheel]);
assert_eq!(rows.len(), 2, "both parameters must remain reachable");
assert!(rows.iter().all(|r| r.kind == "scalar"));
}
fn rows_of(caps: &[OpCapability]) -> Vec<(usize, usize)> { fn rows_of(caps: &[OpCapability]) -> Vec<(usize, usize)> {
let mut rows = Vec::new(); let mut rows = Vec::new();
for op in caps { for op in caps {
@@ -1381,7 +1577,9 @@ mod tests {
.presentation .presentation
.as_ref() .as_ref()
.expect("the curve declares a widget"); .expect("the curve declares a widget");
assert_eq!(presentation.widget, WidgetKind::Curve); // Asked the way the panel asks it: the first preference this frontend
// implements, not a fixed single kind.
assert_eq!(presentation.choose(draws), Some(WidgetKind::ToneCurve));
// Every parameter is owned by the widget, so none is left over to be // Every parameter is owned by the widget, so none is left over to be
// rendered as a stray slider. // rendered as a stray slider.
assert_eq!(presentation.params.len(), curve_cap.params.len()); assert_eq!(presentation.params.len(), curve_cap.params.len());
+130 -422
View File
@@ -9,6 +9,7 @@
import { Theme } from "theme.slint"; import { Theme } from "theme.slint";
import { PanelHeading, Label, Value, Caption, Button, IconButton, Swatch } from "widgets.slint"; import { PanelHeading, Label, Value, Caption, Button, IconButton, Swatch } from "widgets.slint";
import { SliderTrack, ControlRow, CurveEditor, Segmented } from "controls.slint";
// One parameter, flattened for Slint's model system. // One parameter, flattened for Slint's model system.
// //
@@ -43,7 +44,7 @@ export struct ParamRow {
// Which control to build. Mirrors ParamKind, plus the widget kinds an // Which control to build. Mirrors ParamKind, plus the widget kinds an
// operation can request through its presentation. // operation can request through its presentation.
kind: string, // "scalar" | "bool" | "curve" kind: string, // "scalar" | "bool" | "enum" | "curve"
// A run of rows inside a group, for an operation whose parameters form a // A run of rows inside a group, for an operation whose parameters form a
// grid rather than a list. // grid rather than a list.
@@ -83,6 +84,14 @@ export struct ParamRow {
// `param-index` on a curve row is the index of the *first* point // `param-index` on a curve row is the index of the *first* point
// parameter, so a drag routes back by offsetting from it. // parameter, so a drag routes back by offsetting from it.
points: [float], points: [float],
// Enum rows only: the variant names, in index order.
//
// Resolved in Rust against the UI's catalogue, like every other label
// here — the core publishes localisation keys and never a display string.
// `value` on such a row is the chosen index, which is why an enum needs no
// separate selection field.
choices: [string],
} }
// The name of a group of controls, and what can be done to the group. // The name of a group of controls, and what can be done to the group.
@@ -151,165 +160,6 @@ component GroupHeading inherits Rectangle {
} }
} }
// **The** slider. One track, one hit area, one set of gesture rules.
//
// This exists because there used to be two of these, written out separately —
// one reading its geometry from a `ParamRow` for the generated panel, one
// taking plain numbers for the straighten angle — with a comment claiming the
// duplication was safe because "the track behaviour is the same and
// deliberately so". It was not safe and did not stay the same: the moment the
// touch arbitration below was fixed in one copy, the two sliders in the same
// sidebar started behaving differently, and which one you got depended on which
// panel you happened to be dragging in. The wrappers below now differ only in
// where their numbers come from.
component SliderTrack inherits Rectangle {
in property <float> value;
in property <float> default-value;
in property <float> minimum;
in property <float> maximum;
callback changed(float);
callback reset();
/// The pointer is on this control. A scrolling ancestor listens so it can
/// stand down — see the note on `engaged` below.
callback engaged-changed(bool);
height: Theme.touch-target / 2;
// Guarded, because a descriptor with a zero range would otherwise divide
// by nothing and put every position at infinity.
property <float> span: max(0.000001, root.maximum - root.minimum);
// **Why hover, and not the drag itself.**
//
// A Flickable does not merely compete for a gesture, it *withholds* the
// press: `DelayForwarding` holds it back for 100ms and only delivers it if
// nothing has claimed the gesture by then. A finger that starts moving
// inside that window therefore leaves this TouchArea never pressed at all —
// so `moved` never fires, and any handler keyed on the drag having started
// can never run. That is why the panel kept taking sliders away from a
// finger while a tap worked perfectly: a tap's release arrives before the
// 100ms is up, so press and release are delivered together, and only a
// *drag* falls in the hole.
//
// Hover is the one signal that does get through. Move events are dispatched
// to children even while the press is withheld, so the moment a finger
// lands on a track and travels a pixel this goes true, the panel sets
// `interactive: false`, and the Flickable stops arbitrating before it can
// capture anything.
//
// The cost is that a drag *starting* on a track no longer scrolls the
// panel. The label above each track and the padding around it still do, and
// the wheel is unaffected — a Flickable handles wheel events whether or not
// it is interactive.
property <bool> engaged: area.has-hover || area.claimed;
changed engaged => { root.engaged-changed(root.engaged); }
// The rail.
Rectangle {
y: (parent.height - 3px) / 2;
height: 3px;
background: Theme.surface-raised;
border-radius: 1.5px;
}
// The default position, drawn only when it is not at an end. Hand-built
// rather than using the standard Slint slider so this can be marked at
// all — a symmetric control needs to show where zero is.
if root.minimum < root.default-value && root.default-value < root.maximum: Rectangle {
x: (root.default-value - root.minimum) / root.span * parent.width - 1px;
y: (parent.height - 9px) / 2;
width: 2px;
height: 9px;
background: Theme.rule;
}
// Fill from the default to the current value, so the control shows the
// size and direction of the adjustment rather than an absolute magnitude.
Rectangle {
property <length> default-x:
(root.default-value - root.minimum) / root.span * parent.width;
property <length> value-x:
(root.value - root.minimum) / root.span * parent.width;
x: min(self.default-x, self.value-x);
width: abs(self.value-x / 1px - self.default-x / 1px) * 1px;
y: (parent.height - 3px) / 2;
height: 3px;
// The fill is the engaged part of the control — the span the
// photographer has actually moved — so it takes `active` rather than
// the ink the rest of the track is drawn in.
background: Theme.active;
border-radius: 1.5px;
}
Rectangle {
x: (root.value - root.minimum) / root.span * parent.width - 6px;
y: (parent.height - 12px) / 2;
width: 12px;
height: 12px;
border-radius: 6px;
background: area.has-hover || area.pressed ? Theme.ink : Theme.ink-dim;
}
area := TouchArea {
// Explicitly fill the track. A TouchArea with no geometry collapses to
// zero and only reports the events that happen to land on it, which
// shows up as a slider that clicks but does not drag.
width: 100%;
height: 100%;
// Whether this gesture has been claimed as a slider drag.
//
// A scrolling panel acts vertically and this control acts
// horizontally, so the axis of the movement says which was meant.
// Committing on press-down instead — the obvious approach — makes
// every attempt to scroll from a slider jump its value first, which is
// destructive and happens constantly given how much of a panel is
// sliders.
property <bool> claimed: false;
function value-at(px: length) -> float {
return clamp(
root.minimum + (px / self.width) * root.span,
root.minimum,
root.maximum);
}
moved => {
// `moved` fires only while pressed, so this is a drag.
if (!self.claimed
&& abs(self.mouse-x - self.pressed-x)
> abs(self.mouse-y - self.pressed-y)) {
self.claimed = true;
}
if (self.claimed) {
root.changed(self.value-at(self.mouse-x));
}
}
pointer-event(ev) => {
if (ev.kind == PointerEventKind.up
|| ev.kind == PointerEventKind.cancel) {
self.claimed = false;
}
// Right-click resets, alongside double-click.
if (ev.kind == PointerEventKind.down
&& ev.button == PointerEventButton.right) {
root.reset();
}
}
clicked => {
// A press with no meaningful drag: jump to it. Handled on release
// rather than on press so it cannot fire during a scroll that
// merely started here.
if (!self.claimed) {
root.changed(self.value-at(self.mouse-x));
}
}
double-clicked => { root.reset(); }
}
}
// A parameter's value, at the precision its descriptor declares. // A parameter's value, at the precision its descriptor declares.
// //
// A global rather than the same ternary written into every control that shows // A global rather than the same ternary written into every control that shows
@@ -327,6 +177,13 @@ global Readout {
} }
// One generated parameter: a label, a readout, and the track above. // One generated parameter: a label, a readout, and the track above.
//
// The readout is *not* editable, where the settings page's `SliderRow` pairs
// the same track with a number box. That is a considered difference rather than
// an inconsistency: this column is 280px wide and the colour mixer alone puts
// thirty-six of these in it, so a text box per row would be most of the width
// and a keyboard target nobody is aiming for. The number is still reachable —
// the track resets on double-click and right-click.
component ParamSlider inherits Rectangle { component ParamSlider inherits Rectangle {
in property <ParamRow> data; in property <ParamRow> data;
callback changed(float); callback changed(float);
@@ -336,27 +193,13 @@ component ParamSlider inherits Rectangle {
height: 46px; height: 46px;
VerticalLayout { ControlRow {
spacing: 2px; label: root.data.param-label;
readout: Readout.of(root.data);
HorizontalLayout { // The one readout in the panel that has moved off its default is what
Label { // the eye is hunting for, and `modified` is the only thing left to say
text: root.data.param-label; // it with once hue is gone.
emphasised: root.data.value != root.data.default-value;
}
Rectangle { horizontal-stretch: 1; }
Value {
text: Readout.of(root.data);
// The one readout in the panel that has moved off its default
// is what the eye is hunting for, and `modified` is the only
// thing left to say it with once hue is gone.
modified: root.data.value != root.data.default-value; modified: root.data.value != root.data.default-value;
placeholder: root.data.value == root.data.default-value;
compact: true;
}
}
SliderTrack { SliderTrack {
value: root.data.value; value: root.data.value;
@@ -486,24 +329,10 @@ component PlainSlider inherits Rectangle {
height: 46px; height: 46px;
VerticalLayout { ControlRow {
spacing: 2px; label: root.label;
readout: (Math.round(root.value * 10) / 10) + root.unit;
HorizontalLayout {
Label {
text: root.label;
emphasised: root.value != root.default-value;
}
Rectangle { horizontal-stretch: 1; }
Value {
text: (Math.round(root.value * 10) / 10) + root.unit;
modified: root.value != root.default-value; modified: root.value != root.default-value;
placeholder: root.value == root.default-value;
compact: true;
}
}
SliderTrack { SliderTrack {
value: root.value; value: root.value;
@@ -518,6 +347,97 @@ component PlainSlider inherits Rectangle {
} }
} }
// **The control registry: one row in, one control out.**
//
// Slint cannot instantiate a component from a runtime string, so mapping a
// declared kind to a control is necessarily a chain of `if`s. The thing worth
// insisting on is that there is exactly *one* such chain. There were two — the
// panel draws a lone parameter bare and a group under a heading, and each
// branch wrote out its own list of kinds — so `enum` would have had to be added
// in both, and a kind added to only one would appear or vanish depending on how
// many parameters its operation happened to declare.
//
// Everything below routes back through `param-changed` by index. This component
// knows a curve point spans two parameters and a swatch names a hue band; it
// knows nothing about which operation it is drawing, which is the property that
// makes it a registry rather than a panel.
component ParamControl inherits Rectangle {
in property <ParamRow> data;
/// Curve rows only; ignored by every other kind.
in property <[float]> curve-samples;
callback param-changed(int, int, float);
callback param-reset(int, int);
callback curve-reset(int);
callback drag-changed(bool);
height: layout.preferred-height;
layout := VerticalLayout {
spacing: 0px;
alignment: start;
// A row whose subject is a colour is identified by that colour; every
// other scalar keeps its name. The two differ only in what stands in
// for the label — the track, the gestures and the routing are the same
// underneath.
if root.data.kind == "scalar" && root.data.swatch-hue >= 0: SwatchSlider {
data: root.data;
drag-changed(on) => { root.drag-changed(on); }
changed(v) => {
root.param-changed(root.data.op-index, root.data.param-index, v);
}
reset => {
root.param-reset(root.data.op-index, root.data.param-index);
}
}
if root.data.kind == "scalar" && root.data.swatch-hue < 0: ParamSlider {
data: root.data;
drag-changed(on) => { root.drag-changed(on); }
changed(v) => {
root.param-changed(root.data.op-index, root.data.param-index, v);
}
reset => {
root.param-reset(root.data.op-index, root.data.param-index);
}
}
// A fixed list of alternatives. The value *is* the index, so picking
// one is an ordinary parameter change and needs no separate route.
//
// Chips rather than a dropdown for the same reason the settings page
// uses them: these lists are short, and a collapsed menu hides the
// alternatives behind a click. ARCH §4.3 names the dropdown as the
// pointer presentation of the same kind, so this is where that choice
// will be made when the modality switch lands.
if root.data.kind == "enum": Segmented {
label: root.data.param-label;
options: root.data.choices;
selected: Math.round(root.data.value);
picked(i) => {
root.param-changed(root.data.op-index, root.data.param-index, i);
}
}
if root.data.kind == "curve": CurveEditor {
points: root.data.points;
samples: root.curve-samples;
drag-changed(on) => { root.drag-changed(on); }
// A point carries two parameters, so the parameter index is the
// row's base plus the point's offset. This component still knows
// nothing about which operation it belongs to.
point-moved(point, x, y) => {
root.param-changed(
root.data.op-index, root.data.param-index + point * 2, x);
root.param-changed(
root.data.op-index, root.data.param-index + point * 2 + 1, y);
}
reset => { root.curve-reset(root.data.op-index); }
}
}
}
// Crop, rotation, flips and straightening — the framing controls. // Crop, rotation, flips and straightening — the framing controls.
// //
// **Why this is hand-built when the rest of the panel is generated.** The // **Why this is hand-built when the rest of the panel is generated.** The
@@ -632,165 +552,6 @@ export component GeometryPanel inherits Rectangle {
} }
} }
// A tone curve editor: a square grid with draggable control points.
//
// The curve *line* is drawn from `samples`, which Rust evaluates with the
// same spline the shader uses. Reimplementing the interpolation here would
// mean two curves that could disagree — the drawn one and the applied one —
// which is the worst possible failure for a control whose whole job is to
// show you what it is doing.
component CurveEditor inherits Rectangle {
in property <ParamRow> data;
// Polyline of the curve, y values sampled at even x. 0..1, y up.
in property <[float]> samples;
// Which point is being dragged, or -1.
in-out property <int> active-point: -1;
callback point-moved(int, float, float);
callback reset();
/// The pointer is on a control point. The panel stands its Flickable down
/// while it is, for the reason spelled out on `ParamSlider`'s `engaged` —
/// and more acutely here, because a curve point is dragged *vertically*,
/// which is the Flickable's own axis and so is contested every time.
callback drag-changed(bool);
property <int> point-count: root.data.points.length / 2;
// Hover, not the drag: `active-point` is set on press, and the press is
// exactly what a Flickable withholds. Only the plot's grab targets count,
// so the rest of the plot still scrolls the panel.
property <bool> engaged: root.active-point >= 0 || root.hovered-point >= 0;
changed engaged => { root.drag-changed(root.engaged); }
// The point the pointer is over, or -1. Set by the grab targets below,
// and used only to highlight the marker.
in-out property <int> hovered-point: -1;
// Square: a tone curve is read as a deviation from the 45° diagonal, and
// that reading only works if the axes share a scale.
height: self.width;
plot := Rectangle {
background: Theme.ground;
border-width: 1px;
border-color: Theme.rule;
// Quarter gridlines and the identity diagonal, so the shape of the
// edit is legible at a glance.
for i in [1, 2, 3]: Rectangle {
x: parent.width * i / 4;
width: 1px;
background: Theme.rule;
opacity: 0.5;
}
for i in [1, 2, 3]: Rectangle {
y: parent.height * i / 4;
height: 1px;
background: Theme.rule;
opacity: 0.5;
}
// The curve. One thin rectangle per sample: Slint has no polyline
// primitive, and at this size the segments are sub-pixel anyway.
for s[i] in root.samples: Rectangle {
property <float> next: i + 1 < root.samples.length
? root.samples[i + 1] : s;
x: parent.width * i / max(root.samples.length - 1, 1);
width: parent.width / max(root.samples.length - 1, 1) + 1px;
// Span the segment vertically, so a steep section stays joined.
y: parent.height * (1.0 - max(s, self.next));
height: max(parent.height * abs(self.next - s), 1.5px);
background: Theme.active;
}
// Control points.
for idx in [0, 1, 2, 3, 4]: Rectangle {
property <bool> exists: idx < root.point-count;
property <float> px: root.data.points[idx * 2];
property <float> py: root.data.points[idx * 2 + 1];
visible: self.exists;
x: parent.width * self.px - 5px;
y: parent.height * (1.0 - self.py) - 5px;
width: 10px;
height: 10px;
border-radius: 5px;
// Grown and tinted when grabbable, so it is obvious where the
// curve takes the gesture and where the panel scrolls instead.
property <bool> live: root.active-point == idx
|| root.hovered-point == idx;
background: self.live ? Theme.active : Theme.ink;
border-width: 1px;
border-color: Theme.ground;
}
// **One grab target per point, and nothing covering the rest.**
//
// Three constraints meet here, and only this arrangement satisfies
// all of them:
//
// 1. The panel scrolls, and Slint cannot hand back a press once
// taken — so an area spanning the plot would swallow every scroll
// gesture beginning over the curve. Small targets leave the rest
// of the plot free.
// 2. `enabled: false` does not work as a gate: a disabled TouchArea
// recognises *no* events at all, hover included, so it cannot
// report where the pointer is in order to decide.
// 3. A target positioned by its own point would slide out from under
// the pointer on the first movement, stalling the drag. So while
// a point is being dragged its target **freezes** at the press
// position and grows to cover the plot, keeping the pointer
// inside it however far the point travels.
for idx in [0, 1, 2, 3, 4]: TouchArea {
property <bool> exists: idx < root.point-count;
property <bool> dragging: root.active-point == idx;
// Frozen and expanded while dragging; tracking the point
// otherwise.
x: self.dragging ? 0px
: parent.width * root.data.points[idx * 2] - 14px;
y: self.dragging ? 0px
: parent.height * (1.0 - root.data.points[idx * 2 + 1]) - 14px;
width: self.dragging ? parent.width : 28px;
height: self.dragging ? parent.height : 28px;
visible: self.exists;
mouse-cursor: pointer;
// Highlights the marker, so it is visible where the curve takes
// the gesture and where the panel scrolls instead.
changed has-hover => {
if (self.has-hover) {
root.hovered-point = idx;
} else if (root.hovered-point == idx) {
root.hovered-point = -1;
}
}
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down
&& ev.button == PointerEventButton.left) {
root.active-point = idx;
}
if (ev.kind == PointerEventKind.up
|| ev.kind == PointerEventKind.cancel) {
root.active-point = -1;
}
}
moved => {
if (self.dragging) {
// Coordinates are relative to this area, which is the
// whole plot while dragging — so no offset is needed.
root.point-moved(
idx,
clamp(self.mouse-x / parent.width, 0.0, 1.0),
clamp(1.0 - self.mouse-y / parent.height, 0.0, 1.0));
}
}
double-clicked => { root.reset(); }
}
}
}
// The panel: a heading per multi-parameter operation, a control per parameter. // The panel: a heading per multi-parameter operation, a control per parameter.
// //
// **Why the loop is shaped the way it is.** `rows` is flat, and Slint can // **Why the loop is shaped the way it is.** `rows` is flat, and Slint can
@@ -906,33 +667,15 @@ export component AdjustPanel inherits Rectangle {
// //
// `row` is the entry here — the group's head is its only // `row` is the entry here — the group's head is its only
// member — so there is nothing to index back into. // member — so there is nothing to index back into.
if row.group-head == i && row.group-len == 1: VerticalLayout { if row.group-head == i && row.group-len == 1: ParamControl {
spacing: 0px;
if row.kind == "scalar": ParamSlider {
data: row; data: row;
curve-samples: root.curve-samples;
drag-changed(on) => { root.slider-dragging = on; } drag-changed(on) => { root.slider-dragging = on; }
changed(v) => { param-changed(op, param, v) => {
root.param-changed( root.param-changed(op, param, v);
row.op-index, row.param-index, v);
}
reset => {
root.param-reset(row.op-index, row.param-index);
}
}
if row.kind == "curve": CurveEditor {
data: row;
samples: root.curve-samples;
drag-changed(on) => { root.slider-dragging = on; }
point-moved(point, x, y) => {
root.param-changed(
row.op-index, row.param-index + point * 2, x);
root.param-changed(
row.op-index, row.param-index + point * 2 + 1, y);
}
reset => { root.curve-reset(row.op-index); }
} }
param-reset(op, param) => { root.param-reset(op, param); }
curve-reset(op) => { root.curve-reset(op); }
} }
// `if` rather than a zero height: a hidden-but-present // `if` rather than a zero height: a hidden-but-present
@@ -975,52 +718,17 @@ export component AdjustPanel inherits Rectangle {
title: entry.facet-label; title: entry.facet-label;
} }
// A row whose subject is a colour is identified by ParamControl {
// that colour; every other scalar keeps its name.
// The two differ only in what stands in for the
// label — the track, the gestures and the routing
// are the same underneath.
if entry.kind == "scalar" && entry.swatch-hue >= 0: SwatchSlider {
data: entry; data: entry;
curve-samples: root.curve-samples;
drag-changed(on) => { root.slider-dragging = on; } drag-changed(on) => { root.slider-dragging = on; }
changed(v) => { param-changed(op, param, v) => {
root.param-changed( root.param-changed(op, param, v);
entry.op-index, entry.param-index, v);
} }
reset => { param-reset(op, param) => {
root.param-reset(entry.op-index, entry.param-index); root.param-reset(op, param);
}
}
if entry.kind == "scalar" && entry.swatch-hue < 0: ParamSlider {
data: entry;
drag-changed(on) => { root.slider-dragging = on; }
changed(v) => {
root.param-changed(
entry.op-index, entry.param-index, v);
}
reset => {
root.param-reset(entry.op-index, entry.param-index);
}
}
if entry.kind == "curve": CurveEditor {
data: entry;
samples: root.curve-samples;
drag-changed(on) => { root.slider-dragging = on; }
// A point carries two parameters, so the
// parameter index is the row's base plus the
// point's offset. This component still knows
// nothing about which operation it belongs to.
point-moved(point, x, y) => {
root.param-changed(
entry.op-index, entry.param-index + point * 2, x);
root.param-changed(
entry.op-index, entry.param-index + point * 2 + 1, y);
}
reset => {
root.curve-reset(entry.op-index);
} }
curve-reset(op) => { root.curve-reset(op); }
} }
} }
} }
+870
View File
@@ -0,0 +1,870 @@
// The control vocabulary: everything that takes input.
//
// **Why this is a second file rather than more of widgets.slint.** That file
// establishes the rule — screens consume components, and a bare `Theme.*` at a
// call site means a component is missing — and it establishes it for *chrome*:
// buttons, panels, headings, the text roles. Chrome is drawn and read. What is
// below is dragged, typed into and toggled, which is a different kind of thing
// with a different set of concerns: gesture arbitration, validation, hit
// targets, and what a control does when the value moves under it. Keeping them
// apart is what lets either file stay readable.
//
// **The rule this file establishes.** A control's *behaviour* is written once,
// here, and its numbers come from the caller. Before this file the slider lived
// inside the develop panel and was reachable from nowhere else, so the settings
// page — which has a 1-to-100 quality and three other bounded numbers — offered
// a free-text box instead, and `to-float()` silently turned a typo into zero. A
// primitive that only one screen can reach is not a primitive.
//
// **Nothing here knows about `ParamRow`.** That struct is the develop panel's
// flattening of the capability model and lives in adjust.slint; a control that
// imported it would drag the whole develop model into the settings page. The
// primitives take plain numbers and strings, and the ParamRow-shaped wrappers
// stay in the panel that owns the model. This is the constraint that makes the
// file reusable, so it is worth stating rather than merely observing.
import { Theme } from "theme.slint";
import { Icon, Label, Value, Caption, Field } from "widgets.slint";
// --- the slider ---------------------------------------------------------
// **The** slider track. One track, one hit area, one set of gesture rules.
//
// This exists because there used to be two of these, written out separately —
// one reading its geometry from a `ParamRow` for the generated panel, one
// taking plain numbers for the straighten angle — with a comment claiming the
// duplication was safe because "the track behaviour is the same and
// deliberately so". It was not safe and did not stay the same: the moment the
// touch arbitration below was fixed in one copy, the two sliders in the same
// sidebar started behaving differently, and which one you got depended on which
// panel you happened to be dragging in. The wrappers now differ only in where
// their numbers come from.
//
// It moved here from adjust.slint unchanged. The argument above is the reason
// the move matters: a track private to one panel is a track the next screen
// reimplements, which is the same failure one level up.
export component SliderTrack inherits Rectangle {
in property <float> value;
in property <float> default-value;
in property <float> minimum;
in property <float> maximum;
/// Live, once per movement. For anything that should follow the drag: a
/// readout, a preview, the image itself.
callback changed(float);
/// The gesture is over and this is the value to keep.
///
/// **Two callbacks because there are two costs.** Re-rendering a
/// photograph on every movement is the entire point of a develop slider,
/// and the pipeline is built for it. Writing a settings file on every
/// movement is not: a two-second drag is a couple of hundred serialise-
/// and-save round trips where a text field committed once, and on Android
/// that goes through the Storage Access Framework. So a caller whose
/// handler is cheap listens to `changed`, and one whose handler is
/// expensive listens to this — rather than every such caller inventing its
/// own debounce, which is how two of them come to disagree about when an
/// edit is finished.
callback committed(float);
callback reset();
/// The pointer is on this control. A scrolling ancestor listens so it can
/// stand down — see the note on `engaged` below.
callback engaged-changed(bool);
height: Theme.touch-target / 2;
// Guarded, because a descriptor with a zero range would otherwise divide
// by nothing and put every position at infinity.
property <float> span: max(0.000001, root.maximum - root.minimum);
// **Why hover, and not the drag itself.**
//
// A Flickable does not merely compete for a gesture, it *withholds* the
// press: `DelayForwarding` holds it back for 100ms and only delivers it if
// nothing has claimed the gesture by then. A finger that starts moving
// inside that window therefore leaves this TouchArea never pressed at all —
// so `moved` never fires, and any handler keyed on the drag having started
// can never run. That is why the panel kept taking sliders away from a
// finger while a tap worked perfectly: a tap's release arrives before the
// 100ms is up, so press and release are delivered together, and only a
// *drag* falls in the hole.
//
// Hover is the one signal that does get through. Move events are dispatched
// to children even while the press is withheld, so the moment a finger
// lands on a track and travels a pixel this goes true, the panel sets
// `interactive: false`, and the Flickable stops arbitrating before it can
// capture anything.
//
// The cost is that a drag *starting* on a track no longer scrolls the
// panel. The label above each track and the padding around it still do, and
// the wheel is unaffected — a Flickable handles wheel events whether or not
// it is interactive.
property <bool> engaged: area.has-hover || area.claimed;
changed engaged => { root.engaged-changed(root.engaged); }
// The rail.
Rectangle {
y: (parent.height - 3px) / 2;
height: 3px;
background: Theme.surface-raised;
border-radius: 1.5px;
}
// The default position, drawn only when it is not at an end. Hand-built
// rather than using the standard Slint slider so this can be marked at
// all — a symmetric control needs to show where zero is.
if root.minimum < root.default-value && root.default-value < root.maximum: Rectangle {
x: (root.default-value - root.minimum) / root.span * parent.width - 1px;
y: (parent.height - 9px) / 2;
width: 2px;
height: 9px;
background: Theme.rule;
}
// Fill from the default to the current value, so the control shows the
// size and direction of the adjustment rather than an absolute magnitude.
Rectangle {
property <length> default-x:
(root.default-value - root.minimum) / root.span * parent.width;
property <length> value-x:
(root.value - root.minimum) / root.span * parent.width;
x: min(self.default-x, self.value-x);
width: abs(self.value-x / 1px - self.default-x / 1px) * 1px;
y: (parent.height - 3px) / 2;
height: 3px;
// The fill is the engaged part of the control — the span the
// photographer has actually moved — so it takes `active` rather than
// the ink the rest of the track is drawn in.
background: Theme.active;
border-radius: 1.5px;
}
Rectangle {
x: (root.value - root.minimum) / root.span * parent.width - 6px;
y: (parent.height - 12px) / 2;
width: 12px;
height: 12px;
border-radius: 6px;
background: area.has-hover || area.pressed ? Theme.ink : Theme.ink-dim;
}
area := TouchArea {
// Explicitly fill the track. A TouchArea with no geometry collapses to
// zero and only reports the events that happen to land on it, which
// shows up as a slider that clicks but does not drag.
width: 100%;
height: 100%;
// Whether this gesture has been claimed as a slider drag.
//
// A scrolling panel acts vertically and this control acts
// horizontally, so the axis of the movement says which was meant.
// Committing on press-down instead — the obvious approach — makes
// every attempt to scroll from a slider jump its value first, which is
// destructive and happens constantly given how much of a panel is
// sliders.
property <bool> claimed: false;
// The last value this gesture emitted, held until it is committed.
//
// Kept here rather than read back from `root.value` at release: the
// parent may or may not have fed the live value back down, and a
// commit that reported the *old* number on a caller who ignored
// `changed` would silently save the wrong thing.
property <float> pending-value;
property <bool> pending: false;
function value-at(px: length) -> float {
return clamp(
root.minimum + (px / self.width) * root.span,
root.minimum,
root.maximum);
}
function emit(v: float) {
self.pending-value = v;
self.pending = true;
root.changed(v);
}
// Idempotent, because both the pointer-up and the `clicked` that
// follows it are legitimate places to notice a gesture has ended and
// only one of them should commit.
function commit() {
if (self.pending) {
self.pending = false;
root.committed(self.pending-value);
}
}
moved => {
// `moved` fires only while pressed, so this is a drag.
if (!self.claimed
&& abs(self.mouse-x - self.pressed-x)
> abs(self.mouse-y - self.pressed-y)) {
self.claimed = true;
}
if (self.claimed) {
self.emit(self.value-at(self.mouse-x));
}
}
pointer-event(ev) => {
if (ev.kind == PointerEventKind.up
|| ev.kind == PointerEventKind.cancel) {
self.claimed = false;
// A drag ending outside the track never produces `clicked`, so
// the commit cannot wait for one.
self.commit();
}
// Right-click resets, alongside double-click.
if (ev.kind == PointerEventKind.down
&& ev.button == PointerEventButton.right) {
root.reset();
}
}
clicked => {
// A press with no meaningful drag: jump to it. Handled on release
// rather than on press so it cannot fire during a scroll that
// merely started here.
if (!self.claimed) {
self.emit(self.value-at(self.mouse-x));
}
self.commit();
}
double-clicked => { root.reset(); }
}
}
// --- numeric entry ------------------------------------------------------
// A number typed rather than dragged, held to a declared range and precision.
//
// **The parsing is the point.** `Field` plus `to-float()` — which is what the
// settings page did before this existed — cannot tell a rejected entry from a
// deliberate zero, because `to-float()` answers 0 for both. So a mistyped
// export quality silently became 0, was saved, and the page then displayed the
// 0 as though the user had asked for it. `is-float()` is the missing question,
// and asking it is the whole reason this is a component and not a `Field` with
// a call-site handler.
//
// **A rejected entry reverts rather than erroring.** There is nowhere to put a
// validation message on a settings row that would not push every control below
// it down the page, and a control that rejects input by snapping back to the
// last good value has already said what happened. Out-of-range is different
// from unparseable and is *not* rejected: 500 in a 1-to-100 field is a clear
// intention, so it clamps to 100 and shows the 100, which is both what the
// pipeline received and an answer to "why did that not take".
export component NumberField inherits Rectangle {
in property <float> value;
in property <float> minimum;
in property <float> maximum;
/// Decimal places shown, and the precision an entry is held to. Zero for a
/// count, two for a value in stops — the same figure a parameter
/// descriptor declares.
in property <int> precision: 0;
in property <bool> enabled: true;
callback changed(float);
width: 72px;
height: Theme.touch-target;
horizontal-stretch: 0;
opacity: root.enabled ? 1.0 : 0.4;
property <float> factor: Math.pow(10, root.precision);
pure function shown(v: float) -> string {
return Math.round(v * root.factor) / root.factor + "";
}
// The text in the box, deliberately *not* a binding on `value`.
//
// A binding would be broken permanently the first time the TextInput wrote
// through it — Slint drops a binding on assignment — so the box would
// follow the value until the first keystroke and never again. Seeded on
// `init` and rewritten from the two events where rewriting is correct:
// when the value moves under the box (a slider drag, a reset elsewhere),
// and when an entry is committed.
in-out property <string> text;
init => { root.text = root.shown(root.value); }
changed value => { root.text = root.shown(root.value); }
function commit() {
if (root.text.is-float()) {
root.changed(clamp(root.text.to-float(), root.minimum, root.maximum));
}
// Rewritten either way. A valid entry is echoed back clamped and at the
// declared precision, so the box always shows the number the pipeline
// actually holds; an invalid one is discarded and the last good value
// returns.
root.text = root.shown(root.value);
}
field := Field {
width: 100%;
height: 100%;
text <=> root.text;
// Committed on Enter *and* on losing focus, matching `TextRow`: Enter
// alone loses the edit the moment the user clicks the next control,
// which on a page that saves continuously reads as the setting not
// having taken.
accepted => { root.commit(); }
}
property <bool> focused: field.has-focus;
changed focused => {
if (!self.focused) {
root.commit();
}
}
}
// --- booleans -----------------------------------------------------------
// A tick-box and its label: one setting that is either on or off.
//
// Written twice before this — `FormatCheck` in launch.slint and `Switch` in
// settings.slint — from the same 18px box with the same `active` fill and the
// same toggle handler. The second copy carried a comment saying the lift
// belonged here "once a third caller appears", which was the right instinct
// and the wrong threshold: the two copies had already drifted in height, and
// the third caller is this file establishing what a boolean looks like.
//
// **A ticked box fills rather than merely outlining.** `active` is the token
// for an engaged control, and it is what the slider fill and the focus border
// take, so a checked box reads as the same kind of state as those.
export component Check inherits Rectangle {
in property <string> label;
/// Why the default is what it is, for settings where the consequence is
/// not obvious from the name — upscaling being off, location being
/// stripped. Empty on a box whose label says it already; a page of bare
/// switches makes the user guess at what each one costs.
in property <string> hint;
in-out property <bool> checked;
callback toggled(bool);
height: max(row.preferred-height, Theme.control-height);
touch := TouchArea {
// FR-UI-3: the drawn row is shorter than a touch target, so the target
// grows past its own bounds rather than the ink growing.
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
mouse-cursor: pointer;
clicked => {
root.checked = !root.checked;
root.toggled(root.checked);
}
}
row := HorizontalLayout {
spacing: Theme.gap;
alignment: start;
Rectangle {
width: 18px;
height: 18px;
y: (parent.height - self.height) / 2;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: root.checked ? Theme.active : Theme.rule;
background: root.checked ? Theme.active : transparent;
Icon {
name: "check";
// Dark on the fill: `active` is near-white, and the white tick
// this carried against a saturated accent is invisible on it.
ink: Theme.ground;
size: 11px;
visible: root.checked;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
}
VerticalLayout {
spacing: 1px;
alignment: center;
Label { text: root.label; body: true; emphasised: touch.has-hover; }
Caption { text: root.hint; visible: root.hint != ""; wrap: word-wrap; }
}
}
}
// --- one choice out of several ------------------------------------------
// One term in a `Segmented`.
//
// Not `FilterChip`, which widgets.slint keeps for the library's rating filter:
// that one carries a count and has on-off semantics, where this is
// single-selection over a fixed list. They look alike and behave differently,
// which is exactly the case for two components rather than one with a flag.
export component ChoiceChip inherits Rectangle {
in property <string> label;
in property <bool> selected: false;
in property <bool> enabled: true;
callback clicked();
height: Theme.control-height;
// Wide enough that a one-word label is still a comfortable target, which
// is what `control-min-width` exists for — but chips sit several to a row,
// so they take their own narrower floor rather than the button's.
min-width: 64px;
border-radius: Theme.radius;
border-width: 1px;
border-color: root.selected ? Theme.active : Theme.rule;
background: !root.enabled ? transparent
: (root.selected ? Theme.active
: (touch.pressed ? Theme.pressed
: (touch.has-hover ? Theme.hover : transparent)));
opacity: root.enabled ? 1.0 : 0.4;
touch := TouchArea {
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
enabled: root.enabled;
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
Text {
text: root.label;
// Dark on the fill: `active` is near-white and ink on it is invisible.
color: root.selected ? Theme.ground : Theme.ink;
font-size: Theme.text;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
}
}
// --- the labelled-row scaffold ------------------------------------------
// A control's name, with an aside about it on the same line.
//
// Written out three times inside settings.slint before this — once each in the
// choice row, the entry row and the switch — as a body `Label` beside a
// right-aligned elided `Caption`. Three copies of a two-element layout is how
// the hint ends up aligned differently from one row to the next.
//
// The name sits *above* its control rather than beside it: the control rows
// are wide, and a left-hand label column would either crush them or leave the
// page half empty at narrow widths. Stacked, every row uses the full width at
// any window size (FR-UI-1).
export component FieldRow inherits HorizontalLayout {
in property <string> label;
in property <string> hint;
spacing: Theme.gap;
Label { text: root.label; body: true; }
Caption {
text: root.hint;
horizontal-alignment: right;
horizontal-stretch: 1;
overflow: elide;
}
}
// A labelled row of chips: one choice out of a short list.
//
// Not a dropdown. Every choice set on the settings page is short and the
// options are worth reading side by side — a photographer picking an output
// colour space benefits from seeing that ProPhoto exists next to sRGB, which a
// collapsed menu hides behind a click. ARCH §4.3 names the dropdown as the
// *pointer* presentation of the same `Enum`; this is the segmented one, and
// when the modality switch lands it will sit beside a dropdown rather than be
// replaced by one.
export component Segmented inherits VerticalLayout {
in property <string> label;
in property <string> hint;
in property <[string]> options;
in property <int> selected: 0;
in property <bool> enabled: true;
callback picked(int);
spacing: 4px;
FieldRow { label: root.label; hint: root.hint; }
HorizontalLayout {
spacing: Theme.gap-sm;
alignment: start;
for option[i] in root.options: ChoiceChip {
label: option;
selected: i == root.selected;
enabled: root.enabled;
clicked => { root.picked(i); }
}
}
}
// A text entry with its label above and an optional unit after it.
//
// `Field` is `touch-target` tall and stretches, which is right for a server URL
// on the launch screen and wrong for a filename template — so this constrains
// the width rather than restyling the field.
export component TextRow inherits VerticalLayout {
in property <string> label;
in property <string> hint;
in-out property <string> text;
in property <string> unit;
in property <string> placeholder;
in property <bool> enabled: true;
in property <length> field-width: 140px;
callback accepted(string);
spacing: 4px;
FieldRow { label: root.label; hint: root.hint; }
HorizontalLayout {
spacing: Theme.gap-sm;
alignment: start;
Rectangle {
width: root.field-width;
height: field.preferred-height;
opacity: root.enabled ? 1.0 : 0.4;
field := Field {
width: 100%;
text <=> root.text;
placeholder: root.placeholder;
// Committed on Enter *and* on losing focus. Enter alone loses
// an edit the moment the user clicks the next control, which
// on a page that saves continuously reads as the setting not
// having taken.
accepted(t) => { root.accepted(t); }
}
// `Field` reports focus but does not signal losing it, so the
// change is watched here.
property <bool> focused: field.has-focus;
changed focused => {
if (!self.focused) {
root.accepted(root.text);
}
}
}
Label {
text: root.unit;
visible: root.unit != "";
vertical-alignment: center;
height: Theme.touch-target;
}
}
}
// --- sliders that carry their own readout -------------------------------
// A control with its name and current value on the line above it.
//
// The develop panel's shape, generalised: a parameter's name on the left, what
// it currently reads on the right, and the control itself beneath. The control
// arrives as `@children` rather than being built here, so the same header
// serves a track today and whatever else wants one later without this
// component learning what a track is.
//
// **`modified` drives both halves.** A value differing from its default is the
// single thing a photographer scans a panel for, and with hue gone from the
// palette the only signal left is luminance — so the label lights and the
// readout changes ink together, from one fact, rather than two call sites each
// deciding.
export component ControlRow inherits VerticalLayout {
in property <string> label;
/// Already formatted. Precision belongs to whoever owns the value — a
/// descriptor declares it — and a control that rounded on its own would
/// show the same parameter two ways in the same panel.
in property <string> readout;
in property <bool> modified: false;
spacing: 2px;
HorizontalLayout {
Label { text: root.label; emphasised: root.modified; }
Rectangle { horizontal-stretch: 1; }
Value {
text: root.readout;
modified: root.modified;
placeholder: !root.modified;
compact: true;
}
}
@children
}
// A slider and an editable number box for the same value.
//
// **The pointer presentation of a bounded scalar** (ARCH §4.3): the track for
// choosing a value by eye, the box for saying one exactly. Neither alone is
// enough — a track cannot express "exactly 90", and a bare number box makes
// the user guess what the range is until they exceed it.
//
// This is what the settings page's bounded numbers should have been. Export
// quality is 1-to-100 and was a free-text field, so the range was written in a
// hint and enforced nowhere, and `to-float()` turned a typo into zero.
//
// Distinct from `ControlRow` + `SliderTrack`, which is what the develop panel
// uses: there the readout is *not* editable, because that column is 280px wide
// and holds thirty-six of these in the colour mixer alone — a text box per row
// would be most of the width and a keyboard target nobody is aiming for. Two
// presentations of one idea, and which is right depends on how many are on
// screen at once.
export component SliderRow inherits VerticalLayout {
in property <string> label;
in property <string> hint;
in property <float> value;
in property <float> default-value;
in property <float> minimum;
in property <float> maximum;
in property <int> precision: 0;
in property <bool> enabled: true;
/// Fires once per completed gesture, not once per movement.
///
/// This row exists for settings-shaped values, whose handlers persist —
/// so it takes `SliderTrack`'s `committed` rather than its `changed` and
/// spares every call site the debounce. A caller that genuinely wants the
/// live stream, as the develop panel does, composes `ControlRow` with a
/// bare track instead.
callback changed(float);
callback reset();
spacing: 4px;
// What the controls draw while a drag is in flight.
//
// The committed value only arrives at the end of the gesture, so without
// this the handle would sit still under the finger for the whole drag and
// jump at release. Seeded and re-seeded imperatively rather than bound:
// Slint drops a binding on the first assignment, so a bound property would
// follow `value` until the first drag and never again.
property <float> live: root.value;
init => { root.live = root.value; }
changed value => { root.live = root.value; }
// A declared precision is a declared *step*, not merely a display format.
//
// Without this the track hands out the raw position under the finger, so a
// quality of 89.6 reads as "90" in the box — `NumberField` rounds for
// display — and arrives at a caller storing whole numbers as 89. The box
// and the stored value would disagree by one, visibly, on release. Snapping
// here makes the two the same number by construction.
property <float> step-factor: Math.pow(10, root.precision);
pure function quantise(v: float) -> float {
return Math.round(v * root.step-factor) / root.step-factor;
}
FieldRow { label: root.label; hint: root.hint; }
HorizontalLayout {
spacing: Theme.gap;
opacity: root.enabled ? 1.0 : 0.4;
SliderTrack {
horizontal-stretch: 1;
// Centred against the number box, which is a full touch target
// tall where the track is half of one.
y: (parent.height - self.height) / 2;
value: root.live;
default-value: root.default-value;
minimum: root.minimum;
maximum: root.maximum;
changed(v) => { root.live = root.quantise(v); }
committed(v) => { root.changed(root.quantise(v)); }
reset => { root.reset(); }
}
NumberField {
// Follows the drag, so the number and the handle never disagree.
value: root.live;
minimum: root.minimum;
maximum: root.maximum;
precision: root.precision;
enabled: root.enabled;
// A typed entry is already a completed gesture.
changed(v) => { root.changed(v); }
}
}
}
// --- two-dimensional controls -------------------------------------------
// A tone curve editor: a square grid with draggable control points.
//
// The curve *line* is drawn from `samples`, which Rust evaluates with the same
// spline the shader uses. Reimplementing the interpolation here would mean two
// curves that could disagree — the drawn one and the applied one — which is the
// worst possible failure for a control whose whole job is to show you what it
// is doing.
//
// Takes `points` directly rather than the `ParamRow` it used to read, for the
// reason given in this file's preamble: a primitive that knows the develop
// panel's model can only ever be used by the develop panel.
export component CurveEditor inherits Rectangle {
/// Control point coordinates, x and y interleaved, each 0..1 with y up.
in property <[float]> points;
/// Polyline of the curve, y sampled at even x. 0..1, y up.
in property <[float]> samples;
/// Which point is being dragged, or -1.
in-out property <int> active-point: -1;
callback point-moved(int, float, float);
callback reset();
/// The pointer is on a control point. The panel stands its Flickable down
/// while it is, for the reason spelled out on `SliderTrack`'s `engaged` —
/// and more acutely here, because a curve point is dragged *vertically*,
/// which is the Flickable's own axis and so is contested every time.
callback drag-changed(bool);
property <int> point-count: root.points.length / 2;
// Hover, not the drag: `active-point` is set on press, and the press is
// exactly what a Flickable withholds. Only the plot's grab targets count,
// so the rest of the plot still scrolls the panel.
property <bool> engaged: root.active-point >= 0 || root.hovered-point >= 0;
changed engaged => { root.drag-changed(root.engaged); }
// The point the pointer is over, or -1. Set by the grab targets below, and
// used only to highlight the marker.
in-out property <int> hovered-point: -1;
// Square: a tone curve is read as a deviation from the 45° diagonal, and
// that reading only works if the axes share a scale.
height: self.width;
plot := Rectangle {
background: Theme.ground;
border-width: 1px;
border-color: Theme.rule;
// Quarter gridlines and the identity diagonal, so the shape of the
// edit is legible at a glance.
for i in [1, 2, 3]: Rectangle {
x: parent.width * i / 4;
width: 1px;
background: Theme.rule;
opacity: 0.5;
}
for i in [1, 2, 3]: Rectangle {
y: parent.height * i / 4;
height: 1px;
background: Theme.rule;
opacity: 0.5;
}
// The curve. One thin rectangle per sample: Slint has no polyline
// primitive, and at this size the segments are sub-pixel anyway.
for s[i] in root.samples: Rectangle {
property <float> next: i + 1 < root.samples.length
? root.samples[i + 1] : s;
x: parent.width * i / max(root.samples.length - 1, 1);
width: parent.width / max(root.samples.length - 1, 1) + 1px;
// Span the segment vertically, so a steep section stays joined.
y: parent.height * (1.0 - max(s, self.next));
height: max(parent.height * abs(self.next - s), 1.5px);
background: Theme.active;
}
// Control points.
for idx in [0, 1, 2, 3, 4]: Rectangle {
property <bool> exists: idx < root.point-count;
property <float> px: root.points[idx * 2];
property <float> py: root.points[idx * 2 + 1];
visible: self.exists;
x: parent.width * self.px - 5px;
y: parent.height * (1.0 - self.py) - 5px;
width: 10px;
height: 10px;
border-radius: 5px;
// Grown and tinted when grabbable, so it is obvious where the
// curve takes the gesture and where the panel scrolls instead.
property <bool> live: root.active-point == idx
|| root.hovered-point == idx;
background: self.live ? Theme.active : Theme.ink;
border-width: 1px;
border-color: Theme.ground;
}
// **One grab target per point, and nothing covering the rest.**
//
// Three constraints meet here, and only this arrangement satisfies all
// of them:
//
// 1. The panel scrolls, and Slint cannot hand back a press once
// taken — so an area spanning the plot would swallow every scroll
// gesture beginning over the curve. Small targets leave the rest of
// the plot free.
// 2. `enabled: false` does not work as a gate: a disabled TouchArea
// recognises *no* events at all, hover included, so it cannot
// report where the pointer is in order to decide.
// 3. A target positioned by its own point would slide out from under
// the pointer on the first movement, stalling the drag. So while a
// point is being dragged its target **freezes** at the press
// position and grows to cover the plot, keeping the pointer inside
// it however far the point travels.
for idx in [0, 1, 2, 3, 4]: TouchArea {
property <bool> exists: idx < root.point-count;
property <bool> dragging: root.active-point == idx;
// Frozen and expanded while dragging; tracking the point
// otherwise.
x: self.dragging ? 0px
: parent.width * root.points[idx * 2] - 14px;
y: self.dragging ? 0px
: parent.height * (1.0 - root.points[idx * 2 + 1]) - 14px;
width: self.dragging ? parent.width : 28px;
height: self.dragging ? parent.height : 28px;
visible: self.exists;
mouse-cursor: pointer;
// Highlights the marker, so it is visible where the curve takes
// the gesture and where the panel scrolls instead.
changed has-hover => {
if (self.has-hover) {
root.hovered-point = idx;
} else if (root.hovered-point == idx) {
root.hovered-point = -1;
}
}
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down
&& ev.button == PointerEventButton.left) {
root.active-point = idx;
}
if (ev.kind == PointerEventKind.up
|| ev.kind == PointerEventKind.cancel) {
root.active-point = -1;
}
}
moved => {
if (self.dragging) {
// Coordinates are relative to this area, which is the whole
// plot while dragging — so no offset is needed.
root.point-moved(
idx,
clamp(self.mouse-x / parent.width, 0.0, 1.0),
clamp(1.0 - self.mouse-y / parent.height, 0.0, 1.0));
}
}
double-clicked => { root.reset(); }
}
}
}
+3 -57
View File
@@ -1,5 +1,6 @@
import { Theme } from "theme.slint"; import { Theme } from "theme.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, Field, Disclosure, Icon } from "widgets.slint"; import { Button, PanelHeading, Label, Value, Caption, Panel, Field, Disclosure } from "widgets.slint";
import { Check } from "controls.slint";
// Launch screen: connect an account, or resume a saved one. // Launch screen: connect an account, or resume a saved one.
// //
@@ -7,61 +8,6 @@ import { Button, PanelHeading, Label, Value, Caption, Panel, Field, Disclosure,
// with no library configured, and the place they return to in order to sign // with no library configured, and the place they return to in order to sign
// out or switch account (FR-NC-1, FR-NC-4). // out or switch account (FR-NC-1, FR-NC-4).
// One tick-box in the format selection.
component FormatCheck inherits Rectangle {
in property <string> label;
in-out property <bool> checked;
callback toggled(bool);
// FR-UI-3: 44pt minimum hit target under touch. The drawn row is
// shorter, so the touch area extends beyond the visible bounds.
height: 32px;
touch := TouchArea {
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
clicked => {
root.checked = !root.checked;
root.toggled(root.checked);
}
}
HorizontalLayout {
spacing: Theme.gap;
alignment: start;
Rectangle {
width: 18px;
height: 18px;
y: (parent.height - self.height) / 2;
border-radius: 3px;
border-width: 1px;
// A ticked box is an engaged control, so it fills with `active`
// — the same token the slider fill and the focus border take.
border-color: root.checked ? Theme.active : Theme.rule;
background: root.checked ? Theme.active : transparent;
Icon {
name: "check";
// Dark on the fill: `active` is near-white, and the white
// tick this carried against a saturated accent is invisible
// against it.
ink: Theme.ground;
size: 11px;
visible: root.checked;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
}
Label {
text: root.label;
emphasised: touch.has-hover;
body: true;
}
}
}
// This screen's buttons are form actions in a single stacked column, not // This screen's buttons are form actions in a single stacked column, not
// chrome beside a photograph — they are given the full `touch-target` height // chrome beside a photograph — they are given the full `touch-target` height
// rather than the denser `control-height` the toolbars use. `Button` draws // rather than the denser `control-height` the toolbars use. `Button` draws
@@ -395,7 +341,7 @@ export component LaunchScreen inherits Rectangle {
PanelHeading { text: "SCAN FOR"; } PanelHeading { text: "SCAN FOR"; }
for label[i] in root.format-labels: FormatCheck { for label[i] in root.format-labels: Check {
label: label; label: label;
checked: root.format-checked[i]; checked: root.format-checked[i];
toggled(on) => { root.format-toggled(i, on); } toggled(on) => { root.format-toggled(i, on); }
+35 -242
View File
@@ -1,5 +1,6 @@
import { Theme } from "theme.slint"; import { Theme } from "theme.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, Field, ProgressBar, ActivityRow, Icon } from "widgets.slint"; import { Button, PanelHeading, Label, Value, Caption, Panel, ProgressBar, ActivityRow } from "widgets.slint";
import { Segmented, TextRow, Check, SliderRow } from "controls.slint";
// Settings: how much disk the app may spend, and what an export defaults to. // Settings: how much disk the app may spend, and what an export defaults to.
// //
@@ -15,227 +16,6 @@ import { Button, PanelHeading, Label, Value, Caption, Panel, Field, ProgressBar,
// switches views this way — launch, library, develop — and an overlay would be // switches views this way — launch, library, develop — and an overlay would be
// a fourth mechanism for the same job. // a fourth mechanism for the same job.
// One choice out of several, drawn as a row of chips.
//
// Not a dropdown. Every choice set on this page is short and the options are
// worth reading side by side — a photographer picking an output colour space
// benefits from seeing that ProPhoto exists next to sRGB, which a collapsed
// menu hides behind a click. `FilterChip` in widgets.slint is the same idea for
// the library's rating filter, but it carries a count and a filter's
// on-off semantics; this is single-selection over a fixed list, so the
// behaviour differs where it matters.
component ChoiceChip inherits Rectangle {
in property <string> label;
in property <bool> selected: false;
in property <bool> enabled: true;
callback clicked();
height: Theme.control-height;
// Wide enough that a one-word label is still a comfortable target, which
// is what `control-min-width` exists for — but chips sit several to a row,
// so they take their own narrower floor rather than the button's.
min-width: 64px;
border-radius: Theme.radius;
border-width: 1px;
border-color: root.selected ? Theme.active : Theme.rule;
// The selected chip fills, matching the checked box and the slider fill:
// `active` is the token for an engaged control, and this is the one chip in
// the row that is engaged.
background: !root.enabled ? transparent
: (root.selected ? Theme.active
: (touch.pressed ? Theme.pressed
: (touch.has-hover ? Theme.hover : transparent)));
opacity: root.enabled ? 1.0 : 0.4;
touch := TouchArea {
// FR-UI-3: the drawn chip is `control-height`, so the target grows
// past its own bounds rather than the ink growing.
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
enabled: root.enabled;
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
Text {
text: root.label;
// Dark on the fill: `active` is near-white and ink on it is invisible.
color: root.selected ? Theme.ground : Theme.ink;
font-size: Theme.text;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
}
}
// A labelled row of chips, with the label above rather than beside.
//
// Above, because the chip rows are wide and a left-hand label column would
// either crush them or leave the page half empty at narrow widths. Stacked,
// every row uses the full width at any window size (FR-UI-1).
component ChoiceRow inherits VerticalLayout {
in property <string> label;
in property <string> hint;
in property <[string]> options;
in property <int> selected: 0;
in property <bool> enabled: true;
callback picked(int);
spacing: 4px;
HorizontalLayout {
spacing: Theme.gap;
Label { text: root.label; body: true; }
Caption {
text: root.hint;
horizontal-alignment: right;
horizontal-stretch: 1;
overflow: elide;
}
}
HorizontalLayout {
spacing: Theme.gap-sm;
alignment: start;
for option[i] in root.options: ChoiceChip {
label: option;
selected: i == root.selected;
enabled: root.enabled;
clicked => { root.picked(i); }
}
}
}
// A switch: one setting that is either on or off.
//
// The tick-box shape is `FormatCheck`'s from launch.slint, which is the
// established idiom for a boolean in this codebase. Reproduced rather than
// shared because that one is private to the launch screen and lives inside its
// format list; lifting it into widgets.slint would be the better move once a
// third caller appears, and doing it for the second is how a component ends up
// with parameters for every caller's variation.
component Switch inherits Rectangle {
in property <string> label;
in property <string> hint;
in-out property <bool> checked;
callback toggled(bool);
height: max(row.preferred-height, Theme.control-height);
touch := TouchArea {
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
clicked => {
root.checked = !root.checked;
root.toggled(root.checked);
}
}
row := HorizontalLayout {
spacing: Theme.gap;
alignment: start;
Rectangle {
width: 18px;
height: 18px;
y: (parent.height - self.height) / 2;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: root.checked ? Theme.active : Theme.rule;
background: root.checked ? Theme.active : transparent;
Icon {
name: "check";
ink: Theme.ground;
size: 11px;
visible: root.checked;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
}
VerticalLayout {
spacing: 1px;
alignment: center;
Label { text: root.label; body: true; emphasised: touch.has-hover; }
// The hint carries *why* a default is what it is, for the settings
// where that is not obvious from the name — upscaling being off,
// location being stripped. A page of bare switches makes the user
// guess at the consequence of each.
Caption { text: root.hint; visible: root.hint != ""; wrap: word-wrap; }
}
}
}
// A text entry with its label above and an optional unit after it.
//
// `Field` is `touch-target` tall and stretches, which is right for a server
// URL on the launch screen and wrong for a byte count — so this constrains the
// width rather than restyling the field.
component EntryRow inherits VerticalLayout {
in property <string> label;
in property <string> hint;
in-out property <string> text;
in property <string> unit;
in property <string> placeholder;
in property <bool> enabled: true;
in property <length> field-width: 140px;
callback accepted(string);
spacing: 4px;
HorizontalLayout {
spacing: Theme.gap;
Label { text: root.label; body: true; }
Caption {
text: root.hint;
horizontal-alignment: right;
horizontal-stretch: 1;
overflow: elide;
}
}
HorizontalLayout {
spacing: Theme.gap-sm;
alignment: start;
Rectangle {
width: root.field-width;
height: field.preferred-height;
opacity: root.enabled ? 1.0 : 0.4;
field := Field {
width: 100%;
text <=> root.text;
placeholder: root.placeholder;
// Committed on Enter *and* on losing focus. Enter alone loses
// an edit the moment the user clicks the next control, which
// on a page that saves continuously reads as the setting not
// having taken.
accepted(t) => { root.accepted(t); }
}
// `Field` reports focus but does not signal losing it, so the
// change is watched here.
property <bool> focused: field.has-focus;
changed focused => {
if (!self.focused) {
root.accepted(root.text);
}
}
}
Label {
text: root.unit;
visible: root.unit != "";
vertical-alignment: center;
height: Theme.touch-target;
}
}
}
// One background job: what it is, how far along, and what it last said. // One background job: what it is, how far along, and what it last said.
// //
// A row rather than a `Value`/`Caption` pair written out at the call site, // A row rather than a `Value`/`Caption` pair written out at the call site,
@@ -533,7 +313,7 @@ export component SettingsPage inherits Rectangle {
Rectangle { height: Theme.gap-sm; } Rectangle { height: Theme.gap-sm; }
EntryRow { TextRow {
label: "Cached originals"; label: "Cached originals";
hint: "evicted oldest-first when full"; hint: "evicted oldest-first when full";
text <=> root.original-budget; text <=> root.original-budget;
@@ -542,7 +322,7 @@ export component SettingsPage inherits Rectangle {
accepted(t) => { root.original-budget-changed(t); } accepted(t) => { root.original-budget-changed(t); }
} }
Switch { Check {
label: "No limit on cached originals"; label: "No limit on cached originals";
hint: "Nothing is ever evicted for space. " hint: "Nothing is ever evicted for space. "
+ "Pinned photographs are kept regardless."; + "Pinned photographs are kept regardless.";
@@ -552,7 +332,7 @@ export component SettingsPage inherits Rectangle {
Rectangle { height: Theme.gap-sm; } Rectangle { height: Theme.gap-sm; }
EntryRow { TextRow {
label: "Thumbnails and previews"; label: "Thumbnails and previews";
hint: "what the grid draws from"; hint: "what the grid draws from";
text <=> root.thumbnail-budget; text <=> root.thumbnail-budget;
@@ -561,7 +341,7 @@ export component SettingsPage inherits Rectangle {
accepted(t) => { root.thumbnail-budget-changed(t); } accepted(t) => { root.thumbnail-budget-changed(t); }
} }
Switch { Check {
label: "No limit on thumbnails"; label: "No limit on thumbnails";
checked: root.thumbnail-unlimited; checked: root.thumbnail-unlimited;
toggled(on) => { root.thumbnail-unlimited-toggled(on); } toggled(on) => { root.thumbnail-unlimited-toggled(on); }
@@ -569,7 +349,7 @@ export component SettingsPage inherits Rectangle {
Rectangle { height: Theme.gap-sm; } Rectangle { height: Theme.gap-sm; }
Switch { Check {
label: "Keep originals after opening them"; label: "Keep originals after opening them";
hint: "The file was downloaded anyway, so keeping it " hint: "The file was downloaded anyway, so keeping it "
+ "costs no bandwidth and saves the transfer next time."; + "costs no bandwidth and saves the transfer next time.";
@@ -596,26 +376,39 @@ export component SettingsPage inherits Rectangle {
wrap: word-wrap; wrap: word-wrap;
} }
ChoiceRow { Segmented {
label: "Format"; label: "Format";
options: root.format-labels; options: root.format-labels;
selected: root.format-selected; selected: root.format-selected;
picked(i) => { root.format-picked(i); } picked(i) => { root.format-picked(i); }
} }
EntryRow { // A bounded number, so it gets the control for one.
//
// This was a free-text field: the range lived in the
// hint and was enforced nowhere, and `to-float()`
// answers 0 for anything unparseable — so a typo saved
// a quality of 0 and the page then showed the 0 back as
// though it had been asked for. The track carries the
// range and the box refuses what it cannot read.
SliderRow {
label: "Quality"; label: "Quality";
// Says why it is greyed rather than leaving the // Says why it is greyed rather than leaving the
// user to work out that PNG has no quality. // user to work out that PNG has no quality.
hint: root.quality-enabled ? "1 to 100" hint: root.quality-enabled ? "1 to 100"
: "the chosen format is lossless"; : "the chosen format is lossless";
text: root.quality; value: root.quality;
// No meaningful neutral: quality has a sensible
// default but not a *zero*, and a default marker
// partway along a track reads as one.
default-value: 1;
minimum: 1;
maximum: 100;
enabled: root.quality-enabled; enabled: root.quality-enabled;
field-width: 90px; changed(v) => { root.quality-changed(v); }
accepted(t) => { root.quality-changed(t.to-float()); }
} }
ChoiceRow { Segmented {
label: "Colour space"; label: "Colour space";
hint: "profile embedded on export"; hint: "profile embedded on export";
options: root.colour-labels; options: root.colour-labels;
@@ -623,7 +416,7 @@ export component SettingsPage inherits Rectangle {
picked(i) => { root.colour-picked(i); } picked(i) => { root.colour-picked(i); }
} }
ChoiceRow { Segmented {
label: "Size"; label: "Size";
options: root.sizing-labels; options: root.sizing-labels;
selected: root.sizing-selected; selected: root.sizing-selected;
@@ -633,7 +426,7 @@ export component SettingsPage inherits Rectangle {
// Only where the chosen mode carries a number: // Only where the chosen mode carries a number:
// "Original" has none, and a field showing 0 beside it // "Original" has none, and a field showing 0 beside it
// would invite the reading "zero pixels". // would invite the reading "zero pixels".
if root.sizing-has-value: EntryRow { if root.sizing-has-value: TextRow {
label: "Size value"; label: "Size value";
text: root.sizing-value; text: root.sizing-value;
unit: root.sizing-unit; unit: root.sizing-unit;
@@ -641,7 +434,7 @@ export component SettingsPage inherits Rectangle {
accepted(t) => { root.sizing-value-changed(t); } accepted(t) => { root.sizing-value-changed(t); }
} }
Switch { Check {
label: "Allow upscaling"; label: "Allow upscaling";
hint: "Off, a request larger than the source exports " hint: "Off, a request larger than the source exports "
+ "at source size rather than failing."; + "at source size rather than failing.";
@@ -649,7 +442,7 @@ export component SettingsPage inherits Rectangle {
toggled(on) => { root.upscaling-toggled(on); } toggled(on) => { root.upscaling-toggled(on); }
} }
ChoiceRow { Segmented {
label: "Output sharpening"; label: "Output sharpening";
hint: "scaled by the resize factor"; hint: "scaled by the resize factor";
options: root.sharpening-labels; options: root.sharpening-labels;
@@ -674,7 +467,7 @@ export component SettingsPage inherits Rectangle {
PanelHeading { text: "FILES AND METADATA"; } PanelHeading { text: "FILES AND METADATA"; }
EntryRow { TextRow {
label: "Filename template"; label: "Filename template";
hint: "{name} {seq} {date} {dimensions} {preset}"; hint: "{name} {seq} {date} {dimensions} {preset}";
text <=> root.filename-template; text <=> root.filename-template;
@@ -683,7 +476,7 @@ export component SettingsPage inherits Rectangle {
accepted(t) => { root.template-changed(t); } accepted(t) => { root.template-changed(t); }
} }
ChoiceRow { Segmented {
label: "If the file exists"; label: "If the file exists";
options: root.collision-labels; options: root.collision-labels;
selected: root.collision-selected; selected: root.collision-selected;
@@ -695,14 +488,14 @@ export component SettingsPage inherits Rectangle {
// the Storage Access Framework at all, and on any // the Storage Access Framework at all, and on any
// platform a server destination is reached over a // platform a server destination is reached over a
// network that may not be there. // network that may not be there.
ChoiceRow { Segmented {
label: "Export to"; label: "Export to";
options: root.target-labels; options: root.target-labels;
selected: root.target-selected; selected: root.target-selected;
picked(i) => { root.target-picked(i); } picked(i) => { root.target-picked(i); }
} }
EntryRow { TextRow {
label: "Destination"; label: "Destination";
hint: "empty asks each time"; hint: "empty asks each time";
text <=> root.destination; text <=> root.destination;
@@ -711,7 +504,7 @@ export component SettingsPage inherits Rectangle {
accepted(t) => { root.destination-changed(t); } accepted(t) => { root.destination-changed(t); }
} }
Switch { Check {
label: "Strip location and personal metadata"; label: "Strip location and personal metadata";
hint: "On. An export is usually the copy that leaves " hint: "On. An export is usually the copy that leaves "
+ "this machine, and a location embedded in a " + "this machine, and a location embedded in a "