Add the library, collections, and trash views; theme from style.yaml

The UI gains the views the catalog work was building toward: a windowed
library grid with ratings and flags, the collection tree with drag-to-add,
and trash with restore. derived_sync pushes thumbnail shards and the catalog
snapshot to the server's derived folder.

Tokens now have one source of truth. build.rs reads style.yaml and generates
theme.slint into OUT_DIR, which answers every existing
`import { Theme } from "theme.slint"` unchanged, because Slint resolves
imports against the importing file's directory first and the include paths
after. Generating into OUT_DIR rather than beside the hand-written Slint is
the point: a generated file sitting in ui/ looks exactly like the files
around it that are meant to be edited, and an edit to it would survive until
the next touch of style.yaml — a bug that hides for weeks. build.rs fails
loudly if a stale ui/theme.slint exists, which would otherwise shadow the
generated one silently and make every palette change vanish with no error.

The palette moves to near-neutral dark with achromatic signalling, so the
accent means "modified" or "active" rather than "heading". Shared components
land in widgets.slint: a token that binds several values into one concept is
a component, not a row in a YAML file.

Adds an optional live-style feature that makes the tokens in-out so they can
be written at startup — a feature rather than the default because it stops
the properties being constant-folded.

serde_norway is the YAML crate: serde_yaml and serde_yml are both deprecated,
and its mappings preserve insertion order, which is what lets the generated
Slint keep the token ordering the author chose.

Assisted-by: LLM
This commit is contained in:
2026-08-09 21:11:38 +02:00
parent 7900184383
commit 8ad5c86ff9
20 changed files with 12192 additions and 489 deletions
+299 -21
View File
@@ -31,12 +31,36 @@ impl DevelopSession {
pub fn open(ctx: &GpuContext, raw: &RawImage) -> Result<Self, String> {
let demosaicer = Demosaicer::new(ctx).map_err(|e| e.to_string())?;
let demosaiced = demosaicer.run(raw).map_err(|e| e.to_string())?;
Ok(Self::with_source(ctx, demosaiced))
}
Ok(Self {
/// Prepare an edit graph over an already-processed RGB image.
///
/// The JPEG path. A JPEG is already demosaiced, so there is no sensor
/// stage to run — but everything after it is identical, which is why this
/// shares [`Self::with_source`] rather than duplicating the session.
///
/// Worth being honest about what this cannot recover: an 8-bit JPEG has
/// clipped highlights and quantised shadows that no edit brings back, so
/// exposure has far less latitude here than on sensor data. The controls
/// are the same controls; the file simply carries less to work with.
pub fn open_rgb(
ctx: &GpuContext,
rgba: &[u8],
width: u32,
height: u32,
) -> Result<Self, String> {
let source =
DemosaicedImage::from_rgba8(ctx, rgba, width, height).map_err(|e| e.to_string())?;
Ok(Self::with_source(ctx, source))
}
fn with_source(ctx: &GpuContext, demosaiced: DemosaicedImage) -> Self {
Self {
graph: EditGraph::default_chain(),
demosaiced,
adjust: AdjustPass::new(ctx),
})
}
}
/// The controls the interface should show.
@@ -46,6 +70,9 @@ impl DevelopSession {
pub fn rows(&self) -> Vec<ParamRow> {
let mut rows = Vec::new();
for (op_index, op) in self.graph.capabilities().iter().enumerate() {
// Where this operation's rows begin. The panel groups by walking
// back to it, so it has to be taken before any row is pushed.
let group_head = rows.len();
// An operation may ask for one widget spanning several
// parameters. Honouring it is optional — dropping this block
// renders the same parameters as ordinary sliders, and the edit
@@ -55,7 +82,7 @@ impl DevelopSession {
// kind is added, this stops compiling until it is handled,
// rather than silently falling through to sliders.
let row = match presentation.widget {
WidgetKind::Curve => self.curve_row(op_index, op, presentation),
WidgetKind::Curve => self.curve_row(op_index, group_head, op, presentation),
};
if let Some(row) = row {
rows.push(row);
@@ -63,6 +90,17 @@ impl DevelopSession {
}
}
// Whether anything in this operation has been touched, aggregated
// before the rows are built so every row of the group can carry
// the same answer — the panel's heading is one of them and cannot
// see the others.
//
// Derived here rather than asked of the core: a group is a
// composition this side invented, so whether one is modified is
// this side's question to answer (ARCH §4.3a).
let group_modified = op.params.iter().any(|p| p.value != p.default);
let group_len = op.params.len() as i32;
for (param_index, p) in op.params.iter().enumerate() {
let (kind, min, max, precision, unit) = match &p.kind {
ParamKind::Scalar {
@@ -86,9 +124,9 @@ impl DevelopSession {
param_index: param_index as i32,
op_label: labels::resolve(op.label.0).into(),
param_label: labels::resolve(p.label.0).into(),
// The panel draws a heading wherever this is set, without
// needing to know what an operation is.
starts_group: param_index == 0,
group_head: group_head as i32,
group_len,
group_modified,
kind: kind.into(),
value: p.value,
default_value: p.default,
@@ -112,12 +150,13 @@ impl DevelopSession {
fn curve_row(
&self,
op_index: usize,
group_head: usize,
op: &OpCapability,
presentation: &Presentation,
) -> Option<ParamRow> {
// Points are x/y pairs, so an odd count means the operation and this
// code disagree about the layout.
if presentation.params.len() < 2 || presentation.params.len() % 2 != 0 {
if presentation.params.len() < 2 || !presentation.params.len().is_multiple_of(2) {
log::warn!("{}: curve widget needs an even parameter count", op.id);
return None;
}
@@ -148,7 +187,11 @@ impl DevelopSession {
param_index: base as i32,
op_label: labels::resolve(op.label.0).into(),
param_label: String::new().into(),
starts_group: true,
group_head: group_head as i32,
// One widget standing for every parameter of the operation, so
// the group it heads is itself and nothing else.
group_len: 1,
group_modified: op.params.iter().any(|p| p.value != p.default),
kind: "curve".into(),
value: 0.0,
default_value: 0.0,
@@ -207,8 +250,14 @@ impl DevelopSession {
.collect()
}
/// Return every point of a curve operation to its default.
pub fn reset_curve(&mut self, op_index: i32) {
/// Return every parameter of one operation to its default.
///
/// What both a section's reset and a curve's reset do — a curve is one
/// widget spanning all of its operation's parameters, so "reset this
/// curve" and "reset this operation" were always the same action. Nothing
/// here is curve-shaped; it walks whatever parameters the operation
/// declares.
pub fn reset_op(&mut self, op_index: i32) {
let caps = self.graph.capabilities();
let Some(cap) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else {
return;
@@ -218,6 +267,15 @@ impl DevelopSession {
}
}
/// Reset a curve, which is to reset its operation.
///
/// Kept as its own name because the call site is a curve widget's own
/// double-click, and reading `reset_curve` there says why it resets ten
/// parameters at once rather than the one that was clicked.
pub fn reset_curve(&mut self, op_index: i32) {
self.reset_op(op_index);
}
/// Apply a change from the interface.
///
/// Indices are positions in [`Self::rows`]; the mapping back to ids stays
@@ -290,6 +348,46 @@ impl DevelopSession {
Ok(slint::Image::from_rgba8(buffer))
}
/// Render the *whole* frame for the crop overlay to be drawn over.
///
/// Crop mode cannot use [`Self::render`]: that applies the crop, so the
/// area being cropped away would not be on screen and there would be
/// nothing to drag the handles across. This renders as though the crop
/// were full, and the interface draws the rect and greys the surround.
///
/// Zoom is suspended too. Panning a zoomed view while also dragging crop
/// handles is two conflicting meanings for one drag, and the handles are
/// placed against the whole frame in any case.
///
/// Returns the image together with the size it was rendered at, since the
/// overlay has to place its rect against exactly those pixels.
pub fn render_uncropped(
&mut self,
width: u32,
height: u32,
) -> Result<(slint::Image, u32, u32), String> {
let saved_crop = self.graph.crop();
let saved_view = self.graph.framing().view();
self.graph.set_crop(CropRect::default());
self.graph.framing_mut().set_view(CropRect::default());
let result = self.render(width, height);
// Restored whatever happened: leaving the graph cropped-to-full on a
// render error would silently discard the user's crop.
self.graph.set_crop(saved_crop);
self.graph.framing_mut().set_view(saved_view);
let image = result?;
let (sw, sh) = self.demosaiced.size();
// The uncropped frame still turns with the quarter turns, so the
// overlay's box comes from the framing rather than the sensor.
let (fw, fh) = self.graph.framing().output_size_uncropped(sw, sh);
let (rw, rh) = fit(fw, fh, width.max(1), height.max(1));
Ok((image, rw, rh))
}
/// The displayed size, for sizing the viewport.
///
/// The *framed* size, not the sensor's: cropping and quarter turns change
@@ -322,6 +420,87 @@ impl DevelopSession {
self.graph.rotate_quarters(turns);
}
/// How far the viewport is zoomed in: 1.0 fits the frame, 4.0 is 4×.
pub fn zoom(&self) -> f32 {
let v = self.graph.framing().view();
if v.width <= 0.0 {
1.0
} else {
1.0 / v.width
}
}
pub fn is_zoomed(&self) -> bool {
self.graph.framing().is_zoomed()
}
/// Zoom about a point, given in fractions of the *visible* area.
///
/// Anchoring matters: zooming about the pointer keeps whatever is under
/// it stationary, which is what makes a scroll-wheel zoom feel like it is
/// magnifying the photograph rather than sliding it around.
///
/// `factor` multiplies the current zoom — above 1 moves in.
pub fn zoom_about(&mut self, factor: f32, at_x: f32, at_y: f32) {
const MAX_ZOOM: f32 = 16.0;
let view = self.graph.framing().view();
let current = if view.width > 0.0 {
1.0 / view.width
} else {
1.0
};
let target = (current * factor).clamp(1.0, MAX_ZOOM);
// Snapped so scrolling back out reliably reaches "fit" rather than
// stopping a fraction short and leaving the image imperceptibly
// panned.
let target = if (target - 1.0).abs() < 0.01 {
1.0
} else {
target
};
let extent = (1.0 / target).clamp(CropRect::MIN_EXTENT, 1.0);
// The point under the cursor, in framed coordinates, must land back
// under the cursor afterwards.
let anchor_x = view.x + at_x.clamp(0.0, 1.0) * view.width;
let anchor_y = view.y + at_y.clamp(0.0, 1.0) * view.height;
self.set_view_clamped(
anchor_x - at_x.clamp(0.0, 1.0) * extent,
anchor_y - at_y.clamp(0.0, 1.0) * extent,
extent,
);
}
/// Pan by a fraction of the *visible* area — what a drag reports.
pub fn pan_by(&mut self, dx: f32, dy: f32) {
let view = self.graph.framing().view();
self.set_view_clamped(view.x + dx * view.width, view.y + dy * view.height, view.width);
}
/// Back to fitting the whole frame.
pub fn reset_zoom(&mut self) {
self.graph.framing_mut().set_view(CropRect::default());
}
/// Place a square view of `extent`, keeping it inside the frame.
///
/// Clamped rather than allowed to run off the edge: panning past the
/// boundary would show undefined area beside the photograph, which reads
/// as a rendering fault rather than as the end of the image.
fn set_view_clamped(&mut self, x: f32, y: f32, extent: f32) {
let extent = extent.clamp(CropRect::MIN_EXTENT, 1.0);
let max = 1.0 - extent;
self.graph.framing_mut().set_view(CropRect {
x: x.clamp(0.0, max.max(0.0)),
y: y.clamp(0.0, max.max(0.0)),
width: extent,
height: extent,
});
}
/// The largest centred crop that, at the current straightening angle,
/// contains no undefined area. What a "straighten and fill" action
/// applies.
@@ -421,20 +600,119 @@ mod tests {
}
#[test]
fn each_operation_starts_exactly_one_group() {
// The panel draws a heading per group; two groups for one operation
// would duplicate the heading, none would merge two operations under
// one.
fn each_operation_becomes_exactly_one_group() {
// The panel draws one section per group, and derives the boundary
// from `group_head` rather than from a flag the core supplies. Two
// heads for one operation would draw its heading twice; none would
// swallow the operation into the section above it.
let graph = EditGraph::default_chain();
let mut groups = 0;
for op in graph.capabilities() {
for (i, _) in op.params.iter().enumerate() {
if i == 0 {
groups += 1;
}
let caps = graph.capabilities();
// A row heads its group exactly when its own index equals its
// `group_head` — the same test `adjust.slint` makes.
let mut heads = 0;
for (i, row) in rows_of(&caps).iter().enumerate() {
if row.0 == i {
heads += 1;
}
}
assert_eq!(groups, graph.capabilities().len());
assert_eq!(heads, caps.len());
}
#[test]
fn a_group_spans_exactly_its_operations_rows() {
// `group_len` is how many rows the section reaches forward over. Too
// few silently drops controls off the bottom of a section; too many
// reads past the model and renders a neighbouring operation's
// parameters under the wrong heading.
let graph = EditGraph::default_chain();
let caps = graph.capabilities();
let rows = rows_of(&caps);
for (i, row) in rows.iter().enumerate() {
let (head, len) = *row;
assert!(head <= i, "row {i} claims a head after itself");
assert!(
head + len <= rows.len(),
"group at {head} reaches past the model"
);
// Every row the group spans must agree it belongs to that group.
for span in head..head + len {
assert_eq!(rows[span].0, head, "row {span} disagrees about its group");
}
}
}
#[test]
fn a_group_is_modified_when_any_of_its_parameters_is() {
// The dot on a collapsed section is the only thing saying an edit is
// hidden inside it, and it is derived here rather than asked of the
// core (ARCH §4.3a).
let mut graph = EditGraph::default_chain();
let caps = graph.capabilities();
// A fresh chain is at its defaults, so nothing is modified.
assert!(
caps.iter()
.all(|c| c.params.iter().all(|p| p.value == p.default)),
"a fresh chain must start neutral"
);
// Move one parameter of one operation off its default; only that
// operation's group may light up.
let (op_id, param_id, default) = caps
.iter()
.find_map(|c| {
c.params
.iter()
.find(|p| matches!(p.kind, ParamKind::Scalar { .. }))
.map(|p| (c.id, p.id, p.default))
})
.expect("the chain has a scalar parameter");
graph.set_param(op_id, param_id, default + 1.0);
let caps = graph.capabilities();
let modified: Vec<bool> = caps
.iter()
.map(|c| c.params.iter().any(|p| p.value != p.default))
.collect();
assert_eq!(
modified.iter().filter(|m| **m).count(),
1,
"one edit must mark exactly one group"
);
// And it goes out again when the value returns.
graph.set_param(op_id, param_id, default);
assert!(
graph
.capabilities()
.iter()
.all(|c| c.params.iter().all(|p| p.value == p.default)),
"returning a value to its default must clear the group"
);
}
/// `(group_head, group_len)` per row, flattened as
/// [`DevelopSession::rows`] flattens — without needing a GPU to build a
/// session.
///
/// A widget hint only collapses an operation to one row when it is
/// *honoured*; `rows` falls back to sliders otherwise, and mirroring that
/// here is what keeps the test honest when a hint stops applying.
fn rows_of(caps: &[OpCapability]) -> Vec<(usize, usize)> {
let mut rows = Vec::new();
for op in caps {
let head = rows.len();
let collapses = op
.presentation
.as_ref()
.is_some_and(|p| p.params.len() == op.params.len());
let len = if collapses { 1 } else { op.params.len() };
for _ in 0..len {
rows.push((head, len));
}
}
rows
}
#[test]