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
File diff suppressed because it is too large Load Diff
+387
View File
@@ -0,0 +1,387 @@
//! TRACES: FR-CAT-3 | FR-CAT-7 | FR-NC-7
//! Pushing derived state to Nextcloud: thumbnail shards and the catalog.
//!
//! # What travels, and why only this
//!
//! Sidecars are handled elsewhere ([`crate::library::spawn_sidecar_writes`])
//! and are the *authoritative* store — they are the reason a catalog can be
//! deleted and rebuilt (ARCH §6.12). What moves here is derived state that is
//! merely expensive:
//!
//! - **Thumbnail shards.** A thumbnail costs a range fetch plus a decode, and
//! is byte-identical for every client looking at the same file. A second
//! device that downloads the shards gets a full grid without touching a
//! single RAW — hours of indexing against a few hundred MB of transfer.
//! - **The catalog**, for its collections. Every other thing the catalog holds
//! has authoritative backing in a sidecar; a manually assembled collection
//! does not, so without this it exists on one machine only.
//!
//! # Why sealed shards make this cheap
//!
//! A shard stops being written once it reaches its cap, and is never rewritten
//! after — deleting a thumbnail tombstones it in the index rather than editing
//! the sealed blob. So a client that has downloaded a sealed shard never needs
//! to ask about it again, and an up-to-date client transfers only the index and
//! whichever shard is currently open. That is the whole reason for sharding at
//! 25 MB rather than keeping one growing file.
//!
//! # Where it lives
//!
//! Under the library root, in a dotted folder beside the trash. The root is the
//! only place the user granted access to, and writing outside it may cross a
//! share boundary the account cannot write to. The scanner excludes it by the
//! same mechanism that excludes the trash.
use std::path::{Path, PathBuf};
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
use dr_thumbs::ThumbStore;
/// Folder under the library root holding derived state.
///
/// Defined by the scanner, which must exclude it: a walk that indexed this
/// folder would pay a listing for it on every sync of every device.
pub use dr_sync::scan::DERIVED_DIR;
/// What a sync pass did, for logging and for telling the user.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct SyncReport {
pub shards_uploaded: usize,
pub shards_downloaded: usize,
pub thumbnails_adopted: usize,
pub catalog_uploaded: bool,
pub catalog_merged: bool,
pub collections_gained: usize,
}
impl SyncReport {
pub fn did_anything(&self) -> bool {
self.shards_uploaded > 0
|| self.shards_downloaded > 0
|| self.catalog_uploaded
|| self.catalog_merged
}
}
/// Progress from the sync worker.
#[derive(Debug)]
pub enum SyncMessage {
Status(String),
Finished(Box<SyncReport>),
Failed(String),
}
/// Push shards and the catalog, and take anything newer from the server.
///
/// Runs on its own thread with its own runtime, like every other network path
/// here — the Slint loop must never block (NFR-P9).
pub fn spawn_sync(
creds: AppCredentials,
user_id: String,
root: String,
thumbs_dir: PathBuf,
catalog_path: PathBuf,
scratch: PathBuf,
) -> std::sync::mpsc::Receiver<SyncMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(SyncMessage::Failed(e.to_string()));
return;
}
};
rt.block_on(async {
let backend = match NextcloudBackend::new(&creds, &user_id) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(SyncMessage::Failed(e.to_string()));
return;
}
};
match run(&backend, &root, &thumbs_dir, &catalog_path, &scratch, &tx).await {
Ok(report) => {
let _ = tx.send(SyncMessage::Finished(Box::new(report)));
}
Err(e) => {
let _ = tx.send(SyncMessage::Failed(e));
}
}
});
});
rx
}
async fn run(
backend: &NextcloudBackend,
root: &str,
thumbs_dir: &Path,
catalog_path: &Path,
scratch: &Path,
tx: &std::sync::mpsc::Sender<SyncMessage>,
) -> Result<SyncReport, String> {
let mut report = SyncReport::default();
let base = derived_path(root);
// The folder may not exist on a first sync. Creating it unconditionally is
// cheaper than probing, and an existing folder is not an error.
let _ = backend.create_dir(&base).await;
let _ = tx.send(SyncMessage::Status("checking thumbnails…".into()));
sync_shards(backend, &base, thumbs_dir, scratch, &mut report).await?;
let _ = tx.send(SyncMessage::Status("checking collections…".into()));
sync_catalog(backend, &base, catalog_path, scratch, &mut report).await?;
Ok(report)
}
/// The derived folder for a library root.
fn derived_path(root: &str) -> RemotePath {
if root.is_empty() {
RemotePath::new(DERIVED_DIR)
} else {
RemotePath::new(format!("{root}/{DERIVED_DIR}"))
}
}
/// Exchange thumbnail shards with the server.
///
/// Upload what the server lacks, download what we lack. Sealed shards are
/// immutable, so a name match is a content match and nothing needs comparing
/// beyond existence — which is what keeps a steady-state sync to one listing.
async fn sync_shards(
backend: &NextcloudBackend,
base: &RemotePath,
thumbs_dir: &Path,
scratch: &Path,
report: &mut SyncReport,
) -> Result<(), String> {
let store = match ThumbStore::open(thumbs_dir) {
Ok(s) => s,
Err(e) => {
// No local store is not a failure: a fresh device has nothing to
// upload and everything to gain from downloading.
log::debug!("thumbnail store unavailable: {e}");
return Ok(());
}
};
let remote: std::collections::HashMap<String, u64> = backend
.list(base, None)
.await
.map(|entries| {
entries
.into_iter()
.filter(|e| e.kind == dr_sync::EntryKind::File)
.map(|e| (e.path.name().to_string(), e.size))
.collect()
})
// A missing folder lists as an error on some servers; treat it as empty
// rather than aborting a first sync.
.unwrap_or_default();
let local = store.shards().map_err(|e| e.to_string())?;
// ---- upload ----------------------------------------------------------
for shard in &local {
let path = store.shard_path(shard.id);
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
let name = shard_name(shard.id);
// A sealed shard the server already has is byte-identical by
// construction, so its presence is proof enough. The open shard is
// re-uploaded whenever its size differs, which is the only way it
// changes.
let skip = match remote.get(&name) {
Some(_) if shard.sealed => true,
Some(size) => *size == bytes.len() as u64,
None => false,
};
if skip {
continue;
}
let target = RemotePath::new(format!("{}/{name}", base.as_str()));
match backend.put(&target, bytes, None).await {
Ok(_) => report.shards_uploaded += 1,
// One shard failing must not abort the rest: they are independent
// and the next pass retries.
Err(e) => log::warn!("uploading {name}: {e}"),
}
}
// ---- download --------------------------------------------------------
let have: std::collections::HashSet<u32> = local.iter().map(|s| s.id).collect();
let mut store = store;
for (name, _) in &remote {
let Some(id) = shard_id(name) else { continue };
if have.contains(&id) {
continue;
}
let source = RemotePath::new(format!("{}/{name}", base.as_str()));
let bytes = match backend.get(&RemoteId::Path(source), None).await {
Ok(b) => b,
Err(e) => {
log::warn!("downloading {name}: {e}");
continue;
}
};
// Written to scratch and merged, rather than dropped into the store
// directory: a downloaded shard's *id* is the other device's numbering,
// and two devices independently fill shard 0.
let tmp = scratch.join(name);
if std::fs::write(&tmp, &bytes).is_err() {
continue;
}
match store.merge_shard(&tmp) {
Ok(n) => {
report.shards_downloaded += 1;
report.thumbnails_adopted += n;
}
Err(e) => log::warn!("merging {name}: {e}"),
}
let _ = std::fs::remove_file(&tmp);
}
Ok(())
}
/// Exchange the catalog, for its collections.
///
/// Only collections merge — see [`dr_catalog::sync`]. The rest of a catalog
/// describes local state (folder ETags, cache paths, job rows) and importing
/// another device's version would be actively wrong.
async fn sync_catalog(
backend: &NextcloudBackend,
base: &RemotePath,
catalog_path: &Path,
scratch: &Path,
report: &mut SyncReport,
) -> Result<(), String> {
let remote_name = "catalog.sqlite";
let target = RemotePath::new(format!("{}/{remote_name}", base.as_str()));
// ---- take theirs first -----------------------------------------------
//
// Merging before uploading means our upload carries the union rather than
// only our own half, so a third device syncing next gets everything in one
// fetch.
if let Ok(bytes) = backend.get(&RemoteId::Path(target.clone()), None).await {
let downloaded = scratch.join("catalog-remote.sqlite");
if std::fs::write(&downloaded, &bytes).is_ok() {
match dr_catalog::Catalog::open(catalog_path) {
Ok(catalog) => match catalog.merge_remote_catalog(&downloaded) {
Ok(merge) => {
report.catalog_merged = true;
report.collections_gained = merge.inserted + merge.updated;
}
Err(e) => log::warn!("merging remote catalog: {e}"),
},
Err(e) => log::warn!("opening catalog to merge: {e}"),
}
let _ = std::fs::remove_file(&downloaded);
}
}
// ---- then push ours --------------------------------------------------
//
// Never the live file: committed transactions can sit in the `-wal` with
// the main file lagging, so copying it uploads a torn snapshot. The backup
// API serialises against writers instead of racing them.
let snapshot = scratch.join("catalog-upload.sqlite");
let catalog = dr_catalog::Catalog::open(catalog_path).map_err(|e| e.to_string())?;
catalog
.snapshot_for_upload(&snapshot)
.map_err(|e| e.to_string())?;
let bytes = std::fs::read(&snapshot).map_err(|e| e.to_string())?;
match backend.put(&target, bytes, None).await {
Ok(_) => report.catalog_uploaded = true,
Err(e) => log::warn!("uploading catalog: {e}"),
}
let _ = std::fs::remove_file(&snapshot);
Ok(())
}
fn shard_name(id: u32) -> String {
format!("shard-{id:04}.sqlite")
}
/// The shard id in a filename, or `None` if it is not a shard.
///
/// Guards the download loop against adopting the catalog, a stray file, or
/// anything else the folder happens to contain.
fn shard_id(name: &str) -> Option<u32> {
name.strip_prefix("shard-")?
.strip_suffix(".sqlite")?
.parse()
.ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derived_folder_sits_under_the_library_root() {
// Outside the root the account may not have write access — the root is
// the only thing the user granted.
assert_eq!(
derived_path("PhotosRaw").as_str(),
"PhotosRaw/.darkroom-derived"
);
// A library at the account root still gets a relative path.
assert_eq!(derived_path("").as_str(), ".darkroom-derived");
}
#[test]
fn shard_names_round_trip() {
assert_eq!(shard_name(0), "shard-0000.sqlite");
assert_eq!(shard_name(42), "shard-0042.sqlite");
assert_eq!(shard_id("shard-0042.sqlite"), Some(42));
assert_eq!(shard_id(&shard_name(7)), Some(7));
}
#[test]
fn non_shard_files_are_not_adopted() {
// The folder also holds the catalog; downloading it as a shard would
// hand a catalog to the thumbnail merger.
assert_eq!(shard_id("catalog.sqlite"), None);
assert_eq!(shard_id("shard-0000.sqlite-wal"), None);
assert_eq!(shard_id("notes.txt"), None);
assert_eq!(shard_id("shard-abc.sqlite"), None);
}
#[test]
fn a_report_that_did_nothing_says_so() {
assert!(!SyncReport::default().did_anything());
assert!(SyncReport {
shards_uploaded: 1,
..Default::default()
}
.did_anything());
// Adopting thumbnails without moving a shard cannot happen, but the
// report must not claim work on collections alone either.
assert!(SyncReport {
catalog_merged: true,
..Default::default()
}
.did_anything());
}
}
+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]
+66
View File
@@ -29,6 +29,23 @@ pub enum LaunchState {
},
}
/// What the app opens on.
///
/// Three outcomes, and the middle one is easy to lose: a configured library
/// means the launch screen is skipped, and skipping it must not also skip
/// opening the library — otherwise the app lands on an empty view with no
/// route back to the grid, because the "Open library" button is on the screen
/// that was never shown.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Startup {
/// Files were named on the command line; show those.
ShowLocalFiles,
/// An account and a folder are configured; scan and show the grid.
OpenLibrary,
/// Nothing configured; ask the user to sign in.
ShowLaunchScreen,
}
/// TRACES: FR-NC-1 | FR-NC-4 | M-1 | M-3 | M-4
/// Everything the launch screen renders from.
#[derive(Debug, Clone)]
@@ -197,6 +214,23 @@ impl LaunchModel {
self.is_signed_in() && !self.library_root().is_empty()
}
/// What the app should do on startup.
///
/// Pure so it can be tested without a secret store or a display server —
/// and it needs testing, because the interesting case is the one with no
/// visible symptom until you are staring at an empty window.
pub fn startup_action(&self, have_local_paths: bool) -> Startup {
if have_local_paths {
// Files named on the command line win: the user asked for those
// specifically, not for their library.
Startup::ShowLocalFiles
} else if self.can_open_library() {
Startup::OpenLibrary
} else {
Startup::ShowLaunchScreen
}
}
pub fn format_filter(&self) -> FormatFilter {
FormatFilter::from_formats(self.formats.iter().filter(|(_, on)| *on).map(|(f, _)| *f))
}
@@ -393,6 +427,38 @@ mod tests {
assert!(m.can_open_library());
}
#[test]
fn a_configured_library_opens_rather_than_showing_nothing() {
// The regression this guards: skipping the launch screen because a
// library is configured used to mean the library was never opened,
// since `on_open_library` only fires from a button nobody saw.
let mut m = LaunchModel::default();
m.signed_in(session_with_root("PhotosRaw"));
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
}
#[test]
fn no_configuration_shows_the_launch_screen() {
let m = LaunchModel::default();
assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen);
}
#[test]
fn a_signed_in_account_without_a_folder_still_needs_the_screen() {
// Opening without a chosen folder would scan the whole account.
let mut m = LaunchModel::default();
m.signed_in(session_with_root(""));
assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen);
}
#[test]
fn command_line_files_win_over_a_configured_library() {
// The user asked for those files specifically.
let mut m = LaunchModel::default();
m.signed_in(session_with_root("PhotosRaw"));
assert_eq!(m.startup_action(true), Startup::ShowLocalFiles);
}
#[test]
fn a_failure_never_strands_the_screen_in_busy() {
let mut m = LaunchModel::default();
-8
View File
@@ -37,14 +37,6 @@ impl LaunchController {
})
}
/// Whether the app should open on the launch screen.
///
/// Only when there is nothing to show: a configured library goes straight
/// to the images, since making someone click past a login screen they
/// already completed is pure friction.
pub fn should_show(&self, have_local_paths: bool) -> bool {
!have_local_paths && !self.model.borrow().can_open_library()
}
}
/// Push the model into the window's properties.
+479 -46
View File
@@ -14,8 +14,15 @@
//! pipeline what parameters it has and builds a control per answer; no code
//! in `ui/` names an operation or knows a shader exists (FR-DEV-3a).
mod collections_ui;
mod derived_sync;
mod develop;
mod labels;
mod library;
mod library_ui;
mod trash;
#[cfg(live_style)]
mod live_style;
use std::cell::RefCell;
use std::path::{Path, PathBuf};
@@ -48,10 +55,10 @@ const EXPANDED_MIN_WIDTH: f32 = 820.0;
/// Everything loaded for the currently displayed image.
struct Loaded {
/// A develop session where the file could be decoded to sensor data.
/// `None` for a JPEG or a body rawler cannot decode, in which case
/// `fallback` carries an embedded preview and the adjust panel is
/// disabled rather than shown doing nothing.
/// A develop session. `None` only where the file could not be opened for
/// editing at all — a body rawler cannot decode, or a corrupt JPEG — in
/// which case `fallback` carries an embedded preview and the adjust panel
/// is disabled rather than shown doing nothing.
session: Option<DevelopSession>,
fallback: Option<slint::Image>,
meta: Metadata,
@@ -77,33 +84,73 @@ fn load(ctx: Option<&dr_gpu::GpuContext>, path: &Path) -> Result<Loaded, String>
}
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
let meta = dr_decode::metadata(&bytes).unwrap_or_default();
load_bytes(ctx, &bytes)
}
/// Open already-fetched bytes.
///
/// Split from [`load`] because a library image has no local file: it arrives
/// as a WebDAV response body, and writing it to disk purely to read it back
/// would be a round-trip for nothing.
fn load_bytes(ctx: Option<&dr_gpu::GpuContext>, bytes: &[u8]) -> Result<Loaded, String> {
let meta = dr_decode::metadata(bytes).unwrap_or_default();
// Route by what the bytes actually are, not by extension (M-9).
//
// A JPEG has no sensor data and never will, so trying the RAW decoder
// first would be a guaranteed failure whose log line reads like a fault.
// It goes straight to the RGB path instead, which is what makes develop
// mode work on the JPEGs the library already indexes.
let is_jpeg = dr_decode::probe(bytes) == Some(dr_types::Format::Jpeg);
// Try sensor data first. A failure here is expected for JPEGs and for
// bodies rawler does not know, and must not stop the image displaying
// (FR-RAW-4).
if let Some(ctx) = ctx {
match dr_decode::decode(&bytes) {
Ok(raw) => match DevelopSession::open(ctx, &raw) {
Ok(session) => {
let (width, height) = session.source_size();
return Ok(Loaded {
session: Some(session),
fallback: None,
meta,
width,
height,
});
}
Err(e) => log::info!("develop unavailable, showing preview: {e}"),
},
Err(e) => log::info!("no sensor data ({e}); showing preview"),
let opened = if is_jpeg {
dr_decode::decode_jpeg(bytes)
.map_err(|e| e.to_string())
.and_then(|mut p| {
// Fit the device before uploading. A film scan runs to
// 13728×8928, well past the 8192 a typical GPU can hold,
// and refusing it would drop the image back to a
// read-only preview — the very thing this path exists to
// avoid. 8192 is still four times a 4K long edge.
let limit = dr_gpu::DemosaicedImage::max_dimension(ctx);
if p.width.max(p.height) > limit {
log::info!(
"{}×{} exceeds the {limit} texture limit; fitting to it",
p.width,
p.height
);
p.downscale_to(limit);
}
DevelopSession::open_rgb(ctx, &p.rgba, p.width, p.height)
})
} else {
// A failure here is expected for bodies rawler does not know, and
// must not stop the image displaying (FR-RAW-4).
dr_decode::decode(bytes)
.map_err(|e| e.to_string())
.and_then(|raw| DevelopSession::open(ctx, &raw))
};
match opened {
Ok(session) => {
let (width, height) = session.source_size();
return Ok(Loaded {
session: Some(session),
fallback: None,
meta,
width,
height,
});
}
Err(e) => log::info!("develop unavailable, showing preview: {e}"),
}
}
// Fall back to the embedded preview, which is all a JPEG has anyway.
// Fall back to the embedded preview: no GPU, or a file neither decoder
// could open for editing. Read-only, and the adjust panel is disabled.
let mut preview =
dr_decode::extract_preview(&bytes, PreviewSize::Screen).map_err(|e| e.to_string())?;
dr_decode::extract_preview(bytes, PreviewSize::Screen).map_err(|e| e.to_string())?;
preview.downscale_to(MAX_DISPLAY_DIM);
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(
@@ -162,6 +209,21 @@ fn is_supported(p: &Path) -> bool {
.is_some()
}
/// Return the view to its opening state for a newly loaded image.
///
/// Zoom and crop mode are properties of *looking at one photograph*, so
/// carrying them to the next one would leave the second image cropped to a
/// rect chosen for the first.
fn reset_view_state(window: &AppWindow) {
window.set_crop_mode(false);
window.set_zoom(1.0);
window.set_zoomed(false);
window.set_crop_x(0.0);
window.set_crop_y(0.0);
window.set_crop_w(1.0);
window.set_crop_h(1.0);
}
/// Push current parameter values back to the interface.
///
/// The controls are not self-updating: the core clamps values, so what the
@@ -186,10 +248,27 @@ fn sync_rows(
};
if current.len() == rows.row_count() {
for (i, row) in current.into_iter().enumerate() {
for (i, mut row) in current.into_iter().enumerate() {
let existing = rows.row_data(i);
// A curve row carries a *nested* model of point coordinates, and
// `rows()` builds a fresh one each call. Swapping it in would
// destroy the point elements — including the `TouchArea` holding
// the current drag — so the existing model is kept and its values
// written through instead.
//
// It also makes the equality test below meaningful: `ModelRc`
// compares by identity, so a brand-new points model would make
// every curve row look changed on every event.
if let Some(previous) = existing.as_ref() {
if update_points_in_place(&previous.points, &row.points) {
row.points = previous.points.clone();
}
}
// Only touch rows that actually changed, so unrelated controls
// are not needlessly invalidated.
if rows.row_data(i).as_ref() != Some(&row) {
if existing.as_ref() != Some(&row) {
rows.set_row_data(i, row);
}
}
@@ -204,29 +283,167 @@ fn sync_rows(
window.set_curve_samples(slint::ModelRc::new(slint::VecModel::from(samples)));
}
/// Copy `fresh`'s values into `existing`, keeping the model identity.
///
/// Returns `false` where the two differ in length, in which case the caller
/// must take the new model wholesale — the control set itself has changed and
/// there is no drag worth preserving.
fn update_points_in_place(
existing: &slint::ModelRc<f32>,
fresh: &slint::ModelRc<f32>,
) -> bool {
use slint::Model as _;
if existing.row_count() != fresh.row_count() {
return false;
}
for i in 0..fresh.row_count() {
let (Some(new), Some(old)) = (fresh.row_data(i), existing.row_data(i)) else {
continue;
};
// Guarded so an unchanged coordinate does not invalidate its element
// — the same reasoning as the row-level check above.
if new != old {
existing.set_row_data(i, new);
}
}
true
}
/// TRACES: M-13 | M-14
/// Build and run the viewer.
pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let entries = Rc::new(collect(&paths));
log::info!("{} image(s) to browse", entries.len());
// Mutable because the browsing list has two sources: the command line at
// startup, and whatever the library grid is showing when a cell is
// clicked. Opening from the grid replaces this so next/previous walk the
// library the user is actually looking at rather than the arguments they
// launched with.
let entries = Rc::new(RefCell::new(collect(&paths)));
log::info!("{} image(s) to browse", entries.borrow().len());
let window = AppWindow::new()?;
// Set once `show` exists; see where the library grid is wired below.
#[allow(clippy::type_complexity)]
let open_from_library: Rc<RefCell<Option<Rc<dyn Fn(String)>>>> =
Rc::new(RefCell::new(None));
// Before anything binds to a token: the compiled palette is already in
// place, so this only overwrites what style.yaml currently says.
#[cfg(live_style)]
live_style::apply(&window);
// The library grid: scan the remote tree into the catalog, then show what
// was found. Clicking a cell opens it in develop.
//
// Declared out here rather than inside the launch block below because the
// develop side reads `paths` to rebuild its browsing list when an image is
// opened from the grid.
let library = library_ui::LibraryController::new();
// Launch screen: shown when there is nothing to display — no local paths
// and no configured library. A user who has already signed in and chosen
// a folder goes straight to their images (FR-NC-1).
{
let controller = launch_ui::LaunchController::new();
let show = controller.should_show(!paths.is_empty());
window.set_show_launch(show);
launch_ui::wire(&window, controller, |session| {
// Opening a remote library needs the scan-and-cache path, which
// lands with the catalog. Reporting that plainly beats a button
// that silently does nothing.
log::info!("open library requested for {}", session.describe());
let startup = controller
.model
.borrow()
.startup_action(!paths.is_empty());
window.set_show_launch(startup == launch::Startup::ShowLaunchScreen);
let library = library.clone();
let collections = collections_ui::CollectionsController::new();
// The click handler needs `show`, which is built further down because
// it captures the develop session and the GPU context. This cell is
// the knot between them: wired empty here, filled once `show` exists.
// A click before then is a no-op rather than a panic — the grid cannot
// be reached until the window is running, by which point it is set.
let open_from_library = open_from_library.clone();
library_ui::wire(
&window,
library.clone(),
collections.clone(),
move |path| {
let Some(f) = open_from_library.borrow().clone() else {
log::warn!("open requested before the viewer was ready: {path}");
return;
};
f(path);
},
);
// The collections sidebar shares the library's catalog handle rather
// than opening its own: one SQLite connection, so an edit here is
// visible to the grid's next read without a reopen.
//
// The reload closure is the seam between the two controllers. The
// sidebar decides *what* is scoped; the library owns the window, the
// offset and the thumbnail workers, so it is what actually reloads —
// and it must be told the scope before it reads, which is why both
// happen here in one place rather than each controller reaching for the
// other.
{
let weak = window.as_weak();
let lib = library.clone();
let coll = collections.clone();
let lib_ids = library.clone();
let lib_session = library.clone();
collections_ui::wire(
&window,
collections.clone(),
library.catalog(),
move || {
let Some(w) = weak.upgrade() else { return };
// Order matters: `set_scope` clears the trash flag, because
// picking a collection is how you leave the trash. Setting
// the flag second is what lets selecting the trash itself
// survive the call.
lib.set_scope(coll.scope());
lib.set_viewing_trash(coll.viewing_trash());
library_ui::reload(&w, &lib);
},
move || lib_ids.visible_ids(),
// The trash's MOVE and DELETE go to the same account the scan
// and thumbnail workers use.
move || lib_session.session(),
);
}
let weak = window.as_weak();
let store_ctl = controller.clone();
let lib = library.clone();
let coll = collections.clone();
launch_ui::wire(&window, controller.clone(), move |session| {
let Some(w) = weak.upgrade() else { return };
log::info!("opening library for {}", session.describe());
library_ui::open(&w, lib.clone(), coll.clone(), &store_ctl.store, session);
});
if show {
log::info!("no library configured — showing the launch screen");
match startup {
launch::Startup::ShowLaunchScreen => {
log::info!("no library configured — showing the launch screen");
}
launch::Startup::ShowLocalFiles => {
log::info!("{} file(s) named on the command line", paths.len());
}
// Skipping the launch screen must not mean skipping the library:
// the "Open library" button lives on the screen we just bypassed,
// so nothing else would ever start the scan.
launch::Startup::OpenLibrary => {
let session = controller.model.borrow().session().cloned();
if let Some(session) = session {
log::info!("resuming library for {}", session.describe());
library_ui::open(
&window,
library.clone(),
collections.clone(),
&controller.store,
session,
);
}
}
}
}
@@ -246,7 +463,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
}
};
window.set_total(entries.len() as i32);
window.set_total(entries.borrow().len() as i32);
let index = Rc::new(RefCell::new(0usize));
// The current develop session, if the file yielded sensor data.
let session: Rc<RefCell<Option<DevelopSession>>> = Rc::new(RefCell::new(None));
@@ -269,10 +486,25 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return };
let (w, h) = *viewport.borrow();
match s.render(w, h) {
// Crop mode shows the whole frame, or the area being cropped away
// would not be on screen for the handles to drag across. The
// overlay draws the rect on top of it.
let rendered = if window.get_crop_mode() {
s.render_uncropped(w, h).map(|(image, _, _)| image)
} else {
s.render(w, h)
};
match rendered {
Ok(image) => {
window.set_canvas(image);
window.set_load_error("".into());
// The readout and the "Fit" button follow the session
// rather than the gesture, so a clamped zoom shows the
// value that was actually applied.
window.set_zoom(s.zoom());
window.set_zoomed(s.is_zoomed());
}
Err(e) => {
log::warn!("render failed: {e}");
@@ -291,13 +523,20 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let rows = rows.clone();
Rc::new(move |window: &AppWindow| {
let i = *index.borrow();
let Some(path) = entries.get(i) else { return };
// Cloned rather than held: `load` below is slow, and keeping the
// list borrowed across it would panic the moment anything else
// touched `entries`.
let Some(path) = entries.borrow().get(i).cloned() else {
return;
};
let path = path.as_path();
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
reset_view_state(window);
window.set_filename(name.clone().into());
window.set_index(i as i32);
@@ -349,6 +588,108 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
})
};
// Now `show` exists, close the knot left open at the library wiring.
//
// The grid's paths are *remote*: there is no local file to open, so the
// click starts a download and the image appears when it lands. That is a
// whole RAW file over WebDAV, so the wait is real and has to be visible —
// the status line says so rather than leaving a blank frame.
{
let weak = window.as_weak();
let library = library.clone();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
let gpu = gpu.clone();
*open_from_library.borrow_mut() = Some(Rc::new(move |path: String| {
let Some(w) = weak.upgrade() else { return };
let name = path.rsplit('/').next().unwrap_or(&path).to_string();
reset_view_state(&w);
w.set_filename(name.clone().into());
w.set_load_error("".into());
w.set_camera("".into());
w.set_exposure("".into());
w.set_dimensions("".into());
// The grid is one image at a time, so next/previous have nothing
// to walk. Shown as 1 of 1 rather than left reading 0.
w.set_index(0);
w.set_total(1);
let Some((creds, user_id)) = library.credentials() else {
w.set_load_error("no library session".into());
return;
};
log::info!("fetching {path} for develop");
w.set_load_error("Downloading…".into());
let rx = library::spawn_full_fetch(creds, user_id, path.clone());
// Polled on the UI thread rather than joined: a join would freeze
// the window for the length of the download.
let weak = w.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
let gpu = gpu.clone();
let timer = Rc::new(slint::Timer::default());
let held = timer.clone();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(50),
move || {
let Ok(got) = rx.try_recv() else { return };
// Landed — this timer has done its job.
held.stop();
let Some(w) = weak.upgrade() else { return };
let bytes = match got {
Ok(b) => b,
Err(e) => {
log::warn!("{name}: {e}");
w.set_load_error(e.into());
return;
}
};
log::info!("{name}: {} bytes fetched", bytes.len());
match load_bytes(gpu.as_ref(), &bytes) {
Ok(l) => {
w.set_load_error("".into());
w.set_camera(describe_camera(&l.meta).into());
w.set_exposure(describe_exposure(&l.meta).into());
w.set_dimensions(format!("{} × {}", l.width, l.height).into());
match l.session {
Some(s) => {
w.set_adjust_enabled(true);
*session.borrow_mut() = Some(s);
sync_rows(&w, &rows, &session);
redraw(&w);
}
None => {
*session.borrow_mut() = None;
rows.set_vec(Vec::<ParamRow>::new());
w.set_adjust_enabled(false);
if let Some(image) = l.fallback {
w.set_canvas(image);
}
}
}
log::info!("{name}: {}×{}", l.width, l.height);
}
Err(e) => {
log::warn!("{name}: {e}");
*session.borrow_mut() = None;
w.set_adjust_enabled(false);
w.set_load_error(e.into());
}
}
},
);
}));
}
// ---- Adjustment callbacks ------------------------------------------
//
// Generic by construction: they carry indices into the capability list,
@@ -413,6 +754,96 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
});
}
// ---- zoom, pan and crop ---------------------------------------------
//
// Zoom and pan are viewing state and touch no parameter, so unlike the
// handlers above they do not `sync_rows`.
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_zoom_at(move |factor, at_x, at_y| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.zoom_about(factor, at_x, at_y);
}
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_pan_by(move |dx, dy| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.pan_by(dx, dy);
}
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_zoom_reset(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.reset_zoom();
}
redraw(&w);
});
}
{
// Entering crop mode drops the zoom: the handles are placed against
// the whole frame, and a zoomed view would put most of that frame off
// screen where it cannot be dragged.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_crop_mode_toggled(move |on| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
if on {
s.reset_zoom();
let c = s.crop();
w.set_crop_x(c.x);
w.set_crop_y(c.y);
w.set_crop_w(c.width);
w.set_crop_h(c.height);
}
}
w.set_crop_mode(on);
redraw(&w);
});
}
{
// The rect arrives raw from the drag; the session normalises it, and
// the properties are written back from what it actually stored. That
// round trip is what makes an over-drag slide along the edge rather
// than letting the overlay and the pipeline disagree.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_crop_changed(move |x, y, width, height| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_crop(dr_pipeline::CropRect {
x,
y,
width,
height,
});
let c = s.crop();
w.set_crop_x(c.x);
w.set_crop_y(c.y);
w.set_crop_w(c.width);
w.set_crop_h(c.height);
}
redraw(&w);
});
}
{
let weak = window.as_weak();
let index = index.clone();
@@ -420,14 +851,15 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let show = show.clone();
window.on_next_image(move || {
let Some(w) = weak.upgrade() else { return };
if entries.is_empty() {
let len = entries.borrow().len();
if len == 0 {
return;
}
// Read, then write — `*x.borrow_mut() = *x.borrow() + 1` holds
// both borrows at once and panics.
let next = {
let cur = *index.borrow();
(cur + 1) % entries.len()
(cur + 1) % len
};
*index.borrow_mut() = next;
show(&w);
@@ -440,13 +872,14 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let show = show.clone();
window.on_prev_image(move || {
let Some(w) = weak.upgrade() else { return };
if entries.is_empty() {
let len = entries.borrow().len();
if len == 0 {
return;
}
let prev = {
let cur = *index.borrow();
if cur == 0 {
entries.len() - 1
len - 1
} else {
cur - 1
}
@@ -489,7 +922,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
apply_layout_class(&window, size.width as f32 / scale);
}
if !entries.is_empty() {
if !entries.borrow().is_empty() {
show(&window);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+181
View File
@@ -0,0 +1,181 @@
//! Re-reads `style.yaml` at startup, so a palette can be tuned by editing a
//! data file and restarting rather than rebuilding the tree.
//!
//! Compiled only under the `live-style` feature. Release builds have no
//! parser, no file read, and tokens the Slint compiler has folded to
//! constants — this path exists for the ten minutes somebody spends deciding
//! whether `surface` wants two more points of lift.
//!
//! The trick that makes it cheap: under this feature `build.rs` emits the
//! tokens as `in-out` rather than `out`. `Theme.ground` reads identically
//! either way, so not one call site knows this module exists; the only
//! difference is that Slint now generates Rust setters for the global.
//!
//! Failure here is never fatal. A photographer running a debug build with a
//! half-edited YAML should get their window and a warning, not a crash — the
//! build-time path is where a malformed file is an error.
//!
//! The cost of the feature is the table at the bottom: Slint generates one
//! setter per property and nothing indexed, so adding a token to `style.yaml`
//! means adding a line here too. Tolerable because it is debug-only, and the
//! unmatched-name warning says so out loud rather than reloading a no-op —
//! but it is why this is not the primary path.
use serde_norway::Value;
use slint::{Color, ComponentHandle};
use crate::{AppWindow, Theme};
/// Absolute path baked in by `build.rs`, so the binary finds the file
/// regardless of the directory it is launched from.
const STYLE_YAML: &str = env!("DR_STYLE_YAML");
pub fn apply(window: &AppWindow) {
match load() {
Ok(style) => {
let n = style.apply_to(window);
log::info!("live-style: applied {n} token(s) from {STYLE_YAML}");
}
Err(e) => log::warn!("live-style: keeping compiled tokens; {STYLE_YAML}: {e}"),
}
}
struct Style {
colors: Vec<(String, Color)>,
lengths: Vec<(String, f32)>,
}
fn load() -> Result<Style, String> {
let text = std::fs::read_to_string(STYLE_YAML).map_err(|e| e.to_string())?;
let doc: Value = serde_norway::from_str(&text).map_err(|e| e.to_string())?;
let doc = doc.as_mapping().ok_or("top level is not a mapping")?;
let mut colors = Vec::new();
for (name, spec) in group(doc, "colors")? {
// An alias resolves against what has already been read, matching the
// generated Slint where `modified: root.active` refers upward.
if let Some(target) = scalar(spec, "alias") {
if let Some((_, c)) = colors.iter().find(|(n, _): &&(String, Color)| n == &target) {
colors.push((name, *c));
}
continue;
}
if let Some(hex) = value_of(spec).and_then(|v| v.as_str()) {
if let Some(c) = parse_hex(hex) {
colors.push((name, c));
}
}
}
let mut lengths = Vec::new();
for (name, spec) in group(doc, "lengths")? {
if let Some(px) = value_of(spec).and_then(|v| v.as_f64()) {
lengths.push((name, px as f32));
}
}
Ok(Style { colors, lengths })
}
/// Yields the real tokens of a group, skipping the `section:`/`break:`
/// dividers that exist only to shape the generated file.
fn group<'a>(
doc: &'a serde_norway::Mapping,
key: &str,
) -> Result<Vec<(String, &'a Value)>, String> {
let map = doc
.get(key)
.and_then(Value::as_mapping)
.ok_or_else(|| format!("missing `{key}:` map"))?;
Ok(map
.iter()
.filter(|(_, spec)| {
!spec
.as_mapping()
.is_some_and(|m| m.contains_key("section") || m.contains_key("break"))
})
.filter_map(|(k, v)| Some((k.as_str()?.to_string(), v)))
.collect())
}
fn value_of(spec: &Value) -> Option<&Value> {
match spec.as_mapping() {
Some(map) => map.get("value"),
None => Some(spec),
}
}
fn scalar(spec: &Value, field: &str) -> Option<String> {
Some(spec.as_mapping()?.get(field)?.as_str()?.to_string())
}
fn parse_hex(hex: &str) -> Option<Color> {
let d = hex.strip_prefix('#')?;
let byte = |i: usize| u8::from_str_radix(d.get(i..i + 2)?, 16).ok();
match d.len() {
6 => Some(Color::from_rgb_u8(byte(0)?, byte(2)?, byte(4)?)),
8 => Some(Color::from_argb_u8(byte(6)?, byte(0)?, byte(2)?, byte(4)?)),
_ => None,
}
}
impl Style {
/// Slint generates one setter per property rather than anything indexed,
/// so the mapping from token name to setter has to be spelled out. The
/// `theme_tokens!` macro keeps that to one line per token, and an
/// unmatched name is warned about rather than ignored — a token renamed
/// in the YAML and nowhere else would otherwise reload silently as a
/// no-op.
fn apply_to(&self, window: &AppWindow) -> usize {
let theme = window.global::<Theme>();
let mut applied = 0;
macro_rules! theme_tokens {
($set:ident, $list:expr, $($name:literal => $setter:ident),* $(,)?) => {
for (name, v) in $list {
match name.as_str() {
$($name => { theme.$setter(v.clone().into()); applied += 1; })*
other => log::warn!("live-style: no such token `{other}`"),
}
}
};
}
theme_tokens!(set, &self.colors,
"ground" => set_ground,
"surface" => set_surface,
"surface-raised" => set_surface_raised,
"rule" => set_rule,
"ink" => set_ink,
"ink-dim" => set_ink_dim,
"ink-faint" => set_ink_faint,
"hover" => set_hover,
"pressed" => set_pressed,
"active" => set_active,
"active-dim" => set_active_dim,
"active-pressed" => set_active_pressed,
"modified" => set_modified,
"selected" => set_selected,
"selected-ring" => set_selected_ring,
"warn-ink" => set_warn_ink,
);
theme_tokens!(set, &self.lengths,
"gap-sm" => set_gap_sm,
"gap" => set_gap,
"gap-lg" => set_gap_lg,
"text-sm" => set_text_sm,
"text" => set_text,
"text-lg" => set_text_lg,
"text-xl" => set_text_xl,
"radius-sm" => set_radius_sm,
"radius" => set_radius,
"touch-target" => set_touch_target,
"row-height" => set_row_height,
"indent" => set_indent,
"control-height" => set_control_height,
"control-min-width" => set_control_min_width,
);
applied
}
}
+496
View File
@@ -0,0 +1,496 @@
//! TRACES: FR-CAT-15 | NFR-P9
//! Soft delete, restore, and permanent delete against the remote.
//!
//! `dr_catalog::trash` owns the catalog side and does no I/O. This is the other
//! half: the remote `MOVE`/`DELETE`, run on a worker thread, paired with the
//! catalog record in the one order that is safe.
//!
//! # Ordering, which is the whole of the correctness here
//!
//! **Soft delete** — `MOVE` first, record second. The reverse would leave the
//! catalog claiming a file is trashed while it sits in the library; the scan
//! excludes the trash folder, so nothing would ever correct the row.
//!
//! **Restore** — `MOVE` first, record second, for the same reason mirrored.
//!
//! **Purge** — `DELETE` the file, then forget the row, then forget the
//! thumbnail. A crash between steps leaves a trashed row whose file is gone,
//! which the next empty resolves as already-deleted. The other order loses the
//! file silently: no row, no listing, and a scan that will never look in the
//! trash folder — a photograph consuming quota that nothing can find.
//!
//! # Partial failure is normal, not exceptional
//!
//! Forty files is forty requests, and one can fail on permissions while the
//! rest succeed. Every operation here is therefore per-image and reports what
//! actually happened rather than aborting the batch — a trash that gives up
//! halfway with no record of where it stopped is worse than one that reports
//! "38 of 40".
use std::path::PathBuf;
use std::sync::mpsc::Receiver;
use dr_catalog::{trash, Catalog};
use dr_sync::{RemoteBackend, RemoteError, RemoteId, RemotePath};
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
use dr_types::ImageId;
/// What a trash operation reports back to the UI.
#[derive(Debug)]
pub enum TrashMessage {
/// One image finished, successfully or not.
///
/// Per-image rather than per-batch so the UI can show progress on a large
/// selection, and so a failure names the file it happened to.
Progress {
done: usize,
total: usize,
failed: usize,
},
/// The batch finished. `failed` names what did not work, for the status line.
Done {
moved: usize,
failed: Vec<String>,
},
}
/// Which way an image is being moved.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
/// Library → trash folder.
ToTrash,
/// Trash folder → where it came from.
Restore,
}
/// One image to move, resolved before the worker starts.
///
/// Carries the id the `MOVE` addresses and the destination path, so the worker
/// needs no catalog access to do its half — the catalog is not `Send`, and the
/// worker owns a separate connection only for the write-back.
#[derive(Debug, Clone)]
pub struct Move {
pub image_id: ImageId,
/// `oc:fileid` where known, else the path. A stable id survives the move and
/// keeps the thumbnail and sidecar mapping attached.
pub file_id: Option<u64>,
pub from: String,
pub to: String,
}
/// Plan a soft delete: where each image goes in the trash.
///
/// Reads the catalog, so it runs on the UI thread before the worker starts.
/// Skips images already trashed — re-trashing is a no-op, not an error, and the
/// UI can hand over a selection that overlaps the trash.
pub fn plan_trash(
catalog: &Catalog,
root: &str,
images: &[ImageId],
) -> Result<Vec<Move>, dr_catalog::CatalogError> {
let mut out = Vec::new();
for &image in images {
let row: Option<(String, Option<i64>, Option<i64>)> = catalog
.connection()
.query_row(
"SELECT i.source_ref, r.file_id, i.trashed_at
FROM images i LEFT JOIN remote r ON r.image_id = i.id
WHERE i.id = ?1",
[image.0 as i64],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.ok();
let Some((from, file_id, trashed_at)) = row else {
continue;
};
if trashed_at.is_some() {
continue;
}
out.push(Move {
image_id: image,
file_id: file_id.map(|v| v as u64),
to: trash::trash_path(root, image, &from),
from,
});
}
Ok(out)
}
/// Plan a restore: where each trashed image goes back to.
///
/// An image with no recorded origin is skipped rather than guessed at — putting
/// a photograph in the wrong folder is harder to notice, and harder to undo,
/// than leaving it in the trash.
pub fn plan_restore(
catalog: &Catalog,
images: &[ImageId],
) -> Result<Vec<Move>, dr_catalog::CatalogError> {
let mut out = Vec::new();
for &image in images {
let row: Option<(String, Option<String>, Option<i64>)> = catalog
.connection()
.query_row(
"SELECT i.source_ref, i.trashed_from, r.file_id
FROM images i LEFT JOIN remote r ON r.image_id = i.id
WHERE i.id = ?1 AND i.trashed_at IS NOT NULL",
[image.0 as i64],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.ok();
let Some((from, Some(to), file_id)) = row else {
continue;
};
out.push(Move {
image_id: image,
file_id: file_id.map(|v| v as u64),
from,
to,
});
}
Ok(out)
}
/// Move images to or from the trash on a worker thread.
///
/// The catalog is written by the worker, after each successful move, so an
/// interrupted batch leaves the rows it completed correct rather than losing all
/// of them.
pub fn spawn_move(
creds: AppCredentials,
user_id: String,
moves: Vec<Move>,
direction: Direction,
catalog_path: PathBuf,
) -> Receiver<TrashMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let total = moves.len();
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(TrashMessage::Done {
moved: 0,
failed: vec![e.to_string()],
});
return;
}
};
rt.block_on(async {
let backend = match NextcloudBackend::new(&creds, &user_id) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(TrashMessage::Done {
moved: 0,
failed: vec![e.to_string()],
});
return;
}
};
let mut succeeded: Vec<(ImageId, String)> = Vec::new();
let mut failed: Vec<String> = Vec::new();
for (i, mv) in moves.iter().enumerate() {
let id = match mv.file_id {
Some(f) => RemoteId::Stable(f),
None => RemoteId::Path(RemotePath::new(&mv.from)),
};
match backend.move_to(&id, &RemotePath::new(&mv.to)).await {
Ok(()) => succeeded.push((mv.image_id, mv.to.clone())),
Err(e) => {
// Named by file, not by id: the user recognises the
// filename and cannot do anything with a row number.
let name = mv.from.rsplit('/').next().unwrap_or(&mv.from);
log::warn!("moving {name}: {e}");
failed.push(format!("{name}: {e}"));
}
}
let _ = tx.send(TrashMessage::Progress {
done: i + 1,
total,
failed: failed.len(),
});
}
// Record after the moves, in one transaction. A crash before this
// leaves the files moved and the catalog stale — recoverable,
// because the next scan cannot see them in the trash folder and the
// rows still point at paths that 404, which the UI reports.
if !succeeded.is_empty() {
match Catalog::open(&catalog_path) {
Ok(cat) => {
let result = match direction {
Direction::ToTrash => {
trash::record_trashed(cat.connection(), &succeeded, now_secs())
}
Direction::Restore => {
trash::record_restored(cat.connection(), &succeeded)
}
};
if let Err(e) = result {
log::warn!("recording trash state: {e}");
failed.push(format!("catalog: {e}"));
}
}
Err(e) => {
log::warn!("opening catalog to record trash state: {e}");
failed.push(format!("catalog: {e}"));
}
}
}
let _ = tx.send(TrashMessage::Done {
moved: succeeded.len(),
failed,
});
});
});
rx
}
/// Permanently delete trashed images: the file, then the row, then the thumbnail.
///
/// See the module preamble for why that order. `thumbs_dir` is passed so the
/// worker can drop the previews — the shards sync, so a stale entry would keep
/// serving a preview of a deleted photograph on every device.
pub fn spawn_purge(
creds: AppCredentials,
user_id: String,
images: Vec<ImageId>,
paths: Vec<(ImageId, Option<u64>, String)>,
catalog_path: PathBuf,
thumbs_dir: PathBuf,
) -> Receiver<TrashMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let total = paths.len();
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(TrashMessage::Done {
moved: 0,
failed: vec![e.to_string()],
});
return;
}
};
rt.block_on(async {
let backend = match NextcloudBackend::new(&creds, &user_id) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(TrashMessage::Done {
moved: 0,
failed: vec![e.to_string()],
});
return;
}
};
let mut deleted: Vec<ImageId> = Vec::new();
let mut dead_thumbs: Vec<u64> = Vec::new();
let mut failed: Vec<String> = Vec::new();
for (i, (image, file_id, path)) in paths.iter().enumerate() {
let id = match file_id {
Some(f) => RemoteId::Stable(*f),
None => RemoteId::Path(RemotePath::new(path)),
};
match backend.delete(&id, None).await {
Ok(()) => {
deleted.push(*image);
if let Some(f) = file_id {
dead_thumbs.push(*f);
}
}
// Already gone is the goal state, not a failure. Treating it
// as an error would wedge every future empty-trash on a file
// the user had removed by hand.
Err(e) if is_missing(&e) => {
log::debug!("{path} was already gone");
deleted.push(*image);
if let Some(f) = file_id {
dead_thumbs.push(*f);
}
}
Err(e) => {
let name = path.rsplit('/').next().unwrap_or(path);
log::warn!("deleting {name}: {e}");
failed.push(format!("{name}: {e}"));
}
}
let _ = tx.send(TrashMessage::Progress {
done: i + 1,
total,
failed: failed.len(),
});
}
// Rows after the files — see `trash::purge_order`.
if !deleted.is_empty() {
match Catalog::open(&catalog_path) {
Ok(cat) => {
if let Err(e) = trash::forget(cat.connection(), &deleted) {
log::warn!("forgetting purged rows: {e}");
failed.push(format!("catalog: {e}"));
}
}
Err(e) => failed.push(format!("catalog: {e}")),
}
}
// Thumbnails last. A failure here is logged and dropped: the
// photographs are gone, which was the point, and a stale preview is
// a cosmetic problem rather than a reason to report the delete
// failed.
if !dead_thumbs.is_empty() {
match dr_thumbs::ThumbStore::open(&thumbs_dir) {
Ok(mut store) => match store.forget(&dead_thumbs) {
Ok(n) => log::info!("dropped {n} thumbnail(s) for purged images"),
Err(e) => log::warn!("dropping thumbnails: {e}"),
},
Err(e) => log::warn!("opening thumbnail store to drop previews: {e}"),
}
}
let _ = tx.send(TrashMessage::Done {
moved: deleted.len(),
failed,
});
});
let _ = images;
});
rx
}
/// Whether a remote error means the object is not there.
///
/// Kept next to its use rather than in `dr-catalog`: it inspects a
/// `dr_sync::RemoteError`, and the catalog crate neither sees nor should see
/// that type.
fn is_missing(e: &RemoteError) -> bool {
matches!(e, RemoteError::NotFound(_))
}
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn seeded() -> Catalog {
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'PhotosRaw')",
[],
)
.unwrap();
for i in 1..=3i64 {
c.execute(
"INSERT INTO images(id, root_id, source_ref, file_size, added_at)
VALUES (?1, 1, ?2, 1000, 0)",
rusqlite::params![i, format!("PhotosRaw/2019/IMG_{i:04}.CR2")],
)
.unwrap();
c.execute(
"INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)",
rusqlite::params![i, 500 + i],
)
.unwrap();
}
cat
}
fn img(i: u64) -> ImageId {
ImageId(i)
}
#[test]
fn a_trash_plan_targets_the_trash_folder_and_keeps_the_stable_id() {
let cat = seeded();
let plan = plan_trash(&cat, "PhotosRaw", &[img(1)]).unwrap();
assert_eq!(plan.len(), 1);
assert_eq!(plan[0].from, "PhotosRaw/2019/IMG_0001.CR2");
assert!(plan[0].to.contains(".darkroom-trash"), "{}", plan[0].to);
// The stable id is what makes the move keep the thumbnail attached.
assert_eq!(plan[0].file_id, Some(501));
}
#[test]
fn an_already_trashed_image_is_not_trashed_again() {
// The selection can overlap what is already in the trash; a second move
// would relocate the file *within* the trash and lose its origin.
let cat = seeded();
let plan = plan_trash(&cat, "PhotosRaw", &[img(1)]).unwrap();
trash::record_trashed(cat.connection(), &[(img(1), plan[0].to.clone())], 100).unwrap();
assert!(plan_trash(&cat, "PhotosRaw", &[img(1)]).unwrap().is_empty());
}
#[test]
fn a_restore_plan_sends_each_image_back_where_it_came_from() {
let cat = seeded();
let plan = plan_trash(&cat, "PhotosRaw", &[img(1)]).unwrap();
trash::record_trashed(cat.connection(), &[(img(1), plan[0].to.clone())], 100).unwrap();
let back = plan_restore(&cat, &[img(1)]).unwrap();
assert_eq!(back.len(), 1);
assert_eq!(back[0].to, "PhotosRaw/2019/IMG_0001.CR2");
// And it moves *from* the trash.
assert!(back[0].from.contains(".darkroom-trash"));
}
#[test]
fn restoring_an_untrashed_image_plans_nothing() {
let cat = seeded();
assert!(plan_restore(&cat, &[img(2)]).unwrap().is_empty());
}
#[test]
fn a_missing_image_is_skipped_rather_than_planned_against_nothing() {
// The grid's selection can outlive a rescan that removed a row.
let cat = seeded();
assert!(plan_trash(&cat, "PhotosRaw", &[img(999)]).unwrap().is_empty());
assert!(plan_restore(&cat, &[img(999)]).unwrap().is_empty());
}
#[test]
fn a_not_found_on_delete_counts_as_deleted() {
// Otherwise one hand-removed file wedges every future empty-trash.
assert!(is_missing(&RemoteError::NotFound("x".into())));
assert!(!is_missing(&RemoteError::Unsupported("x")));
}
#[test]
fn planning_over_an_empty_selection_is_a_no_op() {
let cat = seeded();
assert!(plan_trash(&cat, "PhotosRaw", &[]).unwrap().is_empty());
assert!(plan_restore(&cat, &[]).unwrap().is_empty());
}
}