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:
+299
-21
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user