Files
DarkRoom/ui/dr-ui/src/lib.rs
T
dtourolleandClaude Opus 5 cf8f5b632f Show the develop frame itself, instead of a photocopy of it
The oldest open item in the project (ARCH §6.1, spike S1, AC-8). Every frame
in develop was read off the GPU into a `SharedPixelBuffer` and handed back to
Slint to upload again: ~7 ms at 4K against a 0.28 ms compute pass, 96% of the
frame spent carrying pixels to the CPU and back so they could be drawn where
they already were.

Slint 1.17 will adopt a `wgpu::Texture` directly, and the whole of what that
needs is arrangement rather than code.

**One device, made before the window.** A texture belongs to the device that
allocated it, so the compute passes and the compositor cannot each open their
own. `GpuContext::new_shared` opens one and hands back the instance and
adapter alongside it; `dr_ui::shared_gpu` gives all four to
`BackendSelector::require_wgpu_29(WGPUConfiguration::Manual { .. })`. That
call has to come before the first window, because creating one selects a
backend for you — which is why the GPU is now opened at the top of `run`
rather than two hundred lines down beside the other controllers.

dr-gpu still names no UI type. It hands out raw wgpu and does not ask who is
compositing (ARCH §6.5a).

**Vulkan only on the shared path**, where headless keeps its GL fallback.
wgpu's GL backend reaches its display through EGL at instance creation, and
before a window exists there is no display handle to give it — so a GL
instance cannot later produce the window surface Slint needs from it. A
machine with no Vulkan gets no shared device and browses without develop,
which is the same degradation as no adapter at all.

**`renderer-femtovg` becomes `renderer-femtovg-wgpu`.** The old one is FemtoVG
over OpenGL and cannot be handed a wgpu texture at all. It is not kept
alongside as a fallback: FemtoVG-over-GL has no branch for an imported
texture, falls through to "render this image into a buffer", gets nothing, and
draws nothing — a blank canvas with no error, which is worse than the failure
it would be papering over. The consequence is stated plainly in the manifest:
the desktop app now needs a working wgpu adapter to open a window.

**Two output textures, not one, and this is the part that is not obvious.**
Slint repaints when the image property *changes*, and it decides that with
`PartialEq` — which for two images over the same `wgpu::Texture` says
"unchanged". A pass that reused a single target would have rendered every
slider move correctly on the GPU and shown none of them: right, and invisible.
`AdjustPass` alternates between two targets, so consecutive frames are
genuinely different values. It also settles the read-while-write question that
one queue was already answering.

`RENDER_ATTACHMENT` is added to both render targets. Neither pass uses it;
Slint rejects an imported texture without it, on the reasoning that a
compositor handed a texture may need to draw into it.

**`AdjustPass::read_output` is deleted rather than gated.** It and
`export_pixels` were the same transfer under two names, and the comments
explaining why they were separate are the point of the whole criterion:
reading pixels back to *display* them is the defect, reading them back to
*encode a file* is the only way a file is made. The display twin is now gone
outright, which is stronger than a feature flag — it cannot be turned back on.
`export_pixels` is untouched and still ungated. The `readback` feature comes
off dr-ui, darkroom-desktop and darkroom-android; it stays in dr-gpu, where it
still gates `RenderTarget::read_pixels` and the segmentation field readback.
`examples/develop` moves to `export_pixels`, which is honest — it writes a
PPM — and so no longer needs the feature.

Four tests, each named for what it protects and each of which fails without a
screen if the property it guards breaks:

- the adjust target satisfies every condition Slint's import checks, asserted
  in the crate that owns the descriptor, because a descriptor that drifts
  fails at runtime on a real display and nothing else would notice;
- consecutive renders are different textures, and the third is the first
  again, so the alternation is a rotation and not an allocation per frame;
- the develop canvas has no CPU pixel buffer and does have a wgpu texture —
  AC-8 itself, in the terms Slint uses;
- consecutive frames compare unequal as `slint::Image`, which is the property
  the repaint actually depends on.

The zoom test's readback moves into the test module. It has to: there is no
library function that copies a displayed frame to the CPU any more, and that
is the point — the round-trip now exists in the test binary and nowhere a
shipping build can reach.

**What is not proven.** No GUI was run. What is verified is that the texture
satisfies the import contract, that the import succeeds, that the canvas is a
texture rather than a buffer, and that consecutive frames are distinguishable.
What is unverified is everything that needs a display: that Slint's FemtoVG
wgpu renderer adopts the Manual configuration on a real surface, that the
picture appears the right way up and the right colour, and the frame timing
that motivated the whole exercise. Android is untouched by testing — the
android backend routes a WGPU29 request to Skia, whose wgpu surface does
handle imported textures, but that is read from the source, not observed.

56 dr-gpu tests and 255 dr-ui tests pass, clippy clean under `-D warnings`,
fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:54:00 +02:00

2297 lines
91 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Slint interface for DarkRoom.
//!
//! A viewer with a develop panel: open a folder of RAW files, decode and
//! demosaic on the GPU, and adjust.
//!
//! **The develop frame never leaves the GPU** (ARCH §6.1, AC-8). Spike S1
//! wired Slint's texture import: [`shared_gpu`] opens one wgpu device and
//! gives it to *both* the compute passes and Slint's renderer, and
//! `DevelopSession::render` then hands the compositor the very texture the
//! adjust pass wrote. What used to be a readback and an upload per frame is
//! now a refcount.
//!
//! `SharedPixelBuffer` still appears in this file and in the library grid, and
//! that is not a relapse: an embedded JPEG preview and a thumbnail are decoded
//! on the CPU and have no texture to hand over. AC-8 is about pixels that were
//! *computed on the GPU* travelling to the CPU and back to be looked at.
//!
//! **The develop panel is generated, not written.** [`develop`] asks the
//! 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 activity;
mod collections_ui;
mod derived_sync;
mod develop;
mod export;
mod labels;
mod library;
mod library_ui;
#[cfg(live_style)]
mod live_style;
mod net_runtime;
mod presets;
mod settings_store;
mod settings_ui;
mod sidecar_cache;
mod trash;
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use anyhow::Result;
use dr_decode::{Metadata, PreviewSize};
pub use develop::DevelopSession;
pub mod launch;
pub mod launch_ui;
slint::include_modules!();
/// TRACES: FR-DSP-1 | NFR-RES-1
/// Longest edge the viewer renders at.
///
/// FR-DSP-1: work at the resolution the viewport needs, not the source
/// resolution. A 5472×3648 preview is 79.8 MB of RGBA; at 2048 it is 11 MB,
/// which is what keeps a folder browsable within NFR-RES-1's budget.
const MAX_DISPLAY_DIM: u32 = 2048;
/// TRACES: FR-UI-1 | FR-UI-2 | M-16
/// Width at which the expanded layout appears (FR-UI-1).
///
/// Logical pixels, not a device check — a narrow desktop window gets the
/// compact layout exactly as a tablet in portrait would.
const EXPANDED_MIN_WIDTH: f32 = 820.0;
/// How long after the last change a draft frame is replaced by a sharp one.
///
/// Above the interval between events in a drag, so an ordinary gesture never
/// reaches it and never renders full resolution mid-motion; well below the
/// point where a photographer would notice waiting for the sharp frame.
const SETTLE_DELAY: std::time::Duration = std::time::Duration::from_millis(120);
/// Re-render the current session into the canvas; `true` asks for a draft.
///
/// Shared rather than passed by reference because most of the callbacks in
/// `run` need it and they each outlive the call that built them, so every one
/// holds its own handle.
type Render = Rc<dyn Fn(&AppWindow, bool)>;
/// Everything loaded for the currently displayed image.
struct Loaded {
/// 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,
width: u32,
height: u32,
}
/// Load one image, preferring the full develop path.
///
/// Reads the whole file: demosaic needs every photosite. The remote path
/// (FR-NC-3) fetches only a byte range for *browsing*, which is why the
/// preview API is separate — this is the develop path, and it is expected to
/// be expensive.
fn load(ctx: Option<&dr_gpu::GpuContext>, path: &Path) -> Result<Loaded, String> {
// A VFS placeholder holds one byte and reading it triggers no fetch
// (ARCH §9.0). Say so plainly rather than reporting a decode failure.
if path
.file_name()
.map(|n| n.to_string_lossy().ends_with(dr_types::PLACEHOLDER_SUFFIX))
.unwrap_or(false)
{
return Err("not downloaded — Nextcloud placeholder".into());
}
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
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);
// How the file stored its pixels. A file that says nothing is taken as
// upright — see `Orientation::from_exif`.
let orientation = meta.orientation.unwrap_or_default();
if let Some(ctx) = ctx {
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, orientation)
})
} 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, orientation))
};
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: 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())?;
preview.downscale_to(MAX_DISPLAY_DIM);
// No graph here to carry the baseline, so the pixels are turned instead.
// Cheaper than it sounds after the downscale, and this path is the one a
// phone without a working GPU lands on — where sideways is most likely.
preview.apply_orientation(orientation);
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(
&preview.rgba,
preview.width,
preview.height,
);
Ok(Loaded {
session: None,
fallback: Some(slint::Image::from_rgba8(buffer)),
meta,
width: preview.width,
height: preview.height,
})
}
/// Collect displayable images from file or directory arguments.
fn collect(paths: &[PathBuf]) -> Vec<PathBuf> {
let mut out = Vec::new();
for p in paths {
if p.is_dir() {
let Ok(entries) = std::fs::read_dir(p) else {
continue;
};
let mut found: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.is_file() && is_supported(p))
.collect();
found.sort();
out.extend(found);
} else if p.is_file() && is_supported(p) {
out.push(p.clone());
}
}
out
}
/// Whether a path names an image DarkRoom can catalogue.
///
/// Includes VFS placeholders: `IMG.CR2.nextcloud` is an image the user has,
/// just not locally (ARCH §9.0). Excluding it would make a synced folder look
/// empty rather than offline, which is the opposite of FR-NC-6c's intent.
fn is_supported(p: &Path) -> bool {
let name = p
.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default();
let name = name
.strip_suffix(dr_types::PLACEHOLDER_SUFFIX)
.unwrap_or(&name);
name.rsplit_once('.')
.map(|(_, ext)| ext.to_ascii_lowercase())
.and_then(|e| dr_types::Format::from_extension(&e))
.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);
window.set_max_straighten(dr_pipeline::framing::MAX_STRAIGHTEN);
window.set_straighten(0.0);
window.set_flip_h(false);
window.set_flip_v(false);
window.set_framing_modified(false);
}
/// Push the framing back to the geometry panel.
///
/// Separate from [`sync_rows`] because framing is no longer *in* the rows —
/// it is presented by its own panel rather than generated (see
/// `DevelopSession::rows`), so nothing else would carry these values across.
///
/// Everything here is written from what the session actually holds rather than
/// from what the gesture asked for: quarter turns wrap, the angle is clamped
/// to the descriptor's range, and the crop is normalised, so the panel must
/// show the applied value or it will disagree with the image.
fn sync_framing(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>>) {
let Some(s) = session.borrow().as_ref().map(|s| {
let (h, v) = s.flips();
let c = s.crop();
(s.angle(), h, v, s.framing_edits_image(), c)
}) else {
return;
};
let (angle, flip_h, flip_v, modified, crop) = s;
window.set_straighten(angle);
window.set_flip_h(flip_h);
window.set_flip_v(flip_v);
window.set_framing_modified(modified);
// The overlay draws from these, and a rotation re-expresses the rect —
// so they have to follow a quarter turn even though no handle moved.
window.set_crop_x(crop.x);
window.set_crop_y(crop.y);
window.set_crop_w(crop.width);
window.set_crop_h(crop.height);
}
/// TRACES: FR-EXP-6 | FR-EXP-9
/// Render the open image at full resolution and place the result.
///
/// Synchronous, on the UI thread, and that is a known compromise rather than
/// an oversight. A full-resolution render plus a Lanczos reduction plus an
/// encode is hundreds of milliseconds on a 24 MP frame, and the window is
/// unresponsive for all of it. It is done this way because the alternative —
/// moving a `DevelopSession` and its GPU pass onto a worker — is a larger
/// change than the button is worth before batch export exists, and a batch is
/// what makes the wait intolerable rather than merely noticeable. The status
/// line says what is happening in the meantime.
fn export_now(
window: &AppWindow,
session: &Rc<RefCell<Option<DevelopSession>>>,
settings: &Rc<settings_ui::SettingsController>,
library: &Rc<library_ui::LibraryController>,
) -> Result<export::Placed, String> {
let mut borrowed = session.borrow_mut();
let Some(session) = borrowed.as_mut() else {
return Err("nothing is open".into());
};
let stored = settings.snapshot();
let frame = session.render_for_export()?;
// The size has to be resolved before the name, because `{dimensions}` is
// one of the tokens the template can carry.
let (tw, th) = dr_export::target_size(
frame.width,
frame.height,
stored.export.sizing,
stored.export.allow_upscaling,
);
let filename = window.get_filename().to_string();
let stem = std::path::Path::new(&filename)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "export".into());
let outbox = match library.session() {
Some((_, s)) => export::outbox_dir(&s.server, &s.user_id),
// No account, so no outbox — a device export still works, and a
// remote one is refused below by `place` rather than here, so the
// message names the setting rather than the plumbing.
None => std::env::temp_dir().join("darkroom-outbox"),
};
let ctx = dr_export::NameContext {
source_stem: &stem,
sequence: 1,
date: "",
width: tw,
height: th,
preset: "",
};
// What counts as "taken" depends on where this is going. A device export
// can look at the folder; a queued one is checked against the outbox,
// since the server cannot be reached from here and may not be reachable
// at all — see `export::stage` for why two queued exports of one name
// both survive regardless.
let target_dir = std::path::PathBuf::from(&stored.export.destination);
let taken = |name: &str| -> bool {
match stored.export.target {
dr_types::ExportTarget::Device => target_dir.join(name).exists(),
dr_types::ExportTarget::Remote => false,
}
};
let name = dr_export::resolve_name(
&stored.export.filename_template,
&ctx,
stored.export.format,
stored.export.collision,
&taken,
)
.ok_or("a file of that name is already there, and the collision setting is Skip")?;
let encoded = dr_export::export(&frame, &stored.export, name).map_err(|e| e.to_string())?;
export::place(
&encoded,
stored.export.target,
&stored.export.destination,
&outbox,
)
}
/// What the export button should say, given where an export would go.
///
/// The label carries the destination because the button is the only place the
/// distinction is visible from: "Export" alone gives no hint whether the file
/// lands on this device or is queued for a server that may be unreachable.
fn refresh_export_label(window: &AppWindow, settings: &Rc<settings_ui::SettingsController>) {
let stored = settings.snapshot();
let label = match stored.export.target {
dr_types::ExportTarget::Device => "Export".to_string(),
dr_types::ExportTarget::Remote => "Export to Nextcloud".to_string(),
};
window.set_export_label(label.into());
}
/// Push current parameter values back to the interface.
///
/// The controls are not self-updating: the core clamps values, so what the
/// user dragged to and what the parameter became can differ, and the control
/// must show the latter.
/// **Updates rows in place; never replaces the model.** Assigning a fresh
/// `ModelRc` tears down and rebuilds every row element — including the
/// `TouchArea` currently tracking the pointer — which cancels the drag in
/// progress. The symptom is a slider that jumps on click but cannot be
/// dragged, because each move event destroys the thing that would deliver
/// the next one.
/// TRACES: FR-CAT-8
/// Apply a fetched sidecar to the open session, now or as soon as it arrives.
///
/// The sidecar fetch is started beside the image fetch and is three orders of
/// magnitude smaller, so it has almost always landed by the time there is a
/// session to apply it to — and this takes it straight from the channel. The
/// timer covers the case where it has not, which is why this is not simply a
/// blocking receive: a slow or stalled sidecar request must not freeze the
/// window with the photograph already decoded and on screen.
///
/// A late arrival redraws, so the image is correct either way; the only
/// difference is whether it was ever briefly shown at its defaults.
fn apply_when_ready(
window: &AppWindow,
rx: Rc<std::sync::mpsc::Receiver<Option<dr_pipeline::Sidecar>>>,
session: &Rc<RefCell<Option<DevelopSession>>>,
rows: &Rc<slint::VecModel<ParamRow>>,
redraw: &Rc<dyn Fn(&AppWindow)>,
) {
// Already here — the overwhelmingly common case.
if let Ok(got) = rx.try_recv() {
if let Some(sidecar) = got {
presets::apply_stored_edit(window, &sidecar, session, rows);
}
return;
}
let weak = window.as_weak();
let session = session.clone();
let rows = rows.clone();
let redraw = redraw.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 };
held.stop();
let Some(w) = weak.upgrade() else { return };
let Some(sidecar) = got else { return };
if presets::apply_stored_edit(&w, &sidecar, &session, &rows) {
redraw(&w);
}
},
);
}
pub(crate) fn sync_rows(
window: &AppWindow,
rows: &Rc<slint::VecModel<ParamRow>>,
session: &Rc<RefCell<Option<DevelopSession>>>,
) {
use slint::Model as _;
// Framing is not in `rows` — it has its own panel — but it *is* parameter
// state that the core may have clamped, so it is pushed back here for the
// same reason and by the same callers. A `reset all` reaches the framing
// too, and without this the geometry panel would keep showing the angle
// and flips of an image that no longer has them.
sync_framing(window, session);
let current = match session.borrow().as_ref() {
Some(s) => s.rows(),
None => Vec::new(),
};
// Whether the drawn curve has to be resampled. Sampling runs the spline 96
// times and builds a fresh model, and `sync_rows` is called on *every*
// parameter event — so doing it unconditionally spent that on every
// exposure or contrast drag, none of which can change the curve's shape.
// Only a moved point can, and the in-place update below is what knows.
let mut curve_moved = false;
if current.len() == rows.row_count() {
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() {
match update_points_in_place(&previous.points, &row.points) {
PointsUpdate::Moved => {
curve_moved = true;
row.points = previous.points.clone();
}
PointsUpdate::Unchanged => row.points = previous.points.clone(),
PointsUpdate::Incompatible => {}
}
}
// Only touch rows that actually changed, so unrelated controls
// are not needlessly invalidated.
if existing.as_ref() != Some(&row) {
rows.set_row_data(i, row);
}
}
} else {
// A different image, so the control set itself changed. Rebuilding
// is correct here — there is no drag to preserve, and the new image's
// curve must be drawn whatever shape it is in.
rows.set_vec(current);
curve_moved = true;
}
if !curve_moved {
return;
}
let samples = match session.borrow().as_ref() {
Some(s) => s.curve_samples(),
None => Vec::new(),
};
// The drawn curve follows the points. Replacing this model wholesale is
// safe where replacing `rows` was not: nothing in it is a drag target.
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>,
) -> PointsUpdate {
use slint::Model as _;
if existing.row_count() != fresh.row_count() {
return PointsUpdate::Incompatible;
}
let mut moved = 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);
moved = true;
}
}
if moved {
PointsUpdate::Moved
} else {
PointsUpdate::Unchanged
}
}
/// What [`update_points_in_place`] found, which decides two things: whether the
/// existing points model can be kept, and whether the drawn curve needs
/// resampling.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum PointsUpdate {
/// Lengths differ. The caller must take the fresh model wholesale — the
/// control set itself changed and there is no drag worth preserving.
Incompatible,
/// At least one coordinate was written through.
Moved,
/// Every coordinate already matched.
Unchanged,
}
/// TRACES: FR-DSP-1 | AC-8
/// Open the one wgpu device the compute passes and the compositor share.
///
/// **This is the whole of the zero-copy display path, and it is four lines of
/// configuration.** A `wgpu::Texture` belongs to the device that allocated it;
/// handing one to a compositor drawing on a *different* device is meaningless,
/// and the two would have to meet through system memory — which is the round
/// trip ARCH §6.1 forbids. So there is exactly one device, made here, before
/// anything else needs it.
///
/// **Called before the window exists, and it must be.** `BackendSelector`
/// installs the Slint platform, and Slint installs a default one the first
/// time a window is created; selecting afterwards is too late. That is why the
/// GPU is opened at the top of [`run`] rather than beside the other
/// controllers, where it used to sit.
///
/// `None` means develop is unavailable and the viewer falls back to embedded
/// previews — the same degradation as a machine with no adapter at all.
fn shared_gpu() -> Option<dr_gpu::GpuContext> {
let shared = match pollster::block_on(dr_gpu::GpuContext::new_shared()) {
Ok(shared) => shared,
Err(e) => {
log::warn!("no shareable GPU: {e}");
return None;
}
};
let dr_gpu::SharedGpu {
ctx,
instance,
adapter,
} = shared;
// `Manual` is the variant that means "render with these, do not open your
// own". The two clones are of wgpu handles, which are refcounts over the
// one device and the one queue — not copies of either.
let configuration = slint::wgpu_29::WGPUConfiguration::Manual {
instance,
adapter,
device: (*ctx.device).clone(),
queue: (*ctx.queue).clone(),
};
if let Err(e) = slint::BackendSelector::new()
.require_wgpu_29(configuration)
.select()
{
// Dropping the context rather than keeping it: Slint has fallen back
// to a renderer that did not adopt our device, so every texture this
// context produces is one the compositor cannot sample. A disabled
// develop panel is a visible, explicable failure; a texture handed
// across devices is undefined behaviour on a good day.
log::warn!("Slint would not adopt the GPU device, develop disabled: {e}");
return None;
}
Some(ctx)
}
/// TRACES: M-13 | M-14
/// Build and run the viewer.
pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// 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());
// Before the window, and it has to be: this selects the Slint backend, and
// creating a window selects one for us. See `shared_gpu`. The device is
// shared by demosaic, the adjust pass and the compositor; without one the
// app still browses through the preview path, just without develop.
let gpu = shared_gpu();
let window = AppWindow::new()?;
match &gpu {
Some(ctx) => {
log::info!("adapter: {} ({:?})", ctx.adapter_name(), ctx.backend());
window.set_adapter(ctx.adapter_name().into());
window.set_backend(format!("{:?}", ctx.backend()).to_uppercase().into());
}
None => window.set_backend("NO GPU".into()),
}
// Every background job reports here, and this draws the bar across the top
// of the shell and fills the settings page's list. Built before the
// controllers because they take a handle to it: a job that starts during
// startup — the scan a resumed session begins immediately — has to have
// somewhere to report to before it starts, or its first minute is invisible.
let activity = activity::ActivityLog::new();
activity.attach(&window);
{
let activity = activity.clone();
window.on_activity_clear_finished(move || activity.clear_finished());
}
// 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));
// TRACES: FR-CAT-8
// The same knot, for the other direction: leaving develop has to persist
// the edit, and the grid's "‹ Library" button is wired before the develop
// session exists to save from. Empty until then, and calling it is a no-op
// rather than a panic — there is nothing open to lose.
#[allow(clippy::type_complexity)]
let leave_develop: Rc<RefCell<Option<Rc<dyn Fn()>>>> = 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(activity.clone());
// Hoisted out of the launch block below because the settings clipboard
// needs it too: a paste onto "the selection" reads the selection from
// here, and that wiring happens once the develop session exists.
let collections = collections_ui::CollectionsController::new(activity.clone());
// 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 startup = controller.model.borrow().startup_action(!paths.is_empty());
window.set_show_launch(startup == launch::Startup::ShowLaunchScreen);
let library = library.clone();
let collections = collections.clone();
// 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();
let leave_develop = leave_develop.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);
},
Rc::new(move || {
if let Some(f) = leave_develop.borrow().clone() {
f();
}
}),
);
// 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);
});
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,
);
}
}
}
}
// Settings: cache ceilings and export defaults, in their own config file.
//
// Wired independently of every view above. It reads no library and holds no
// session, so it has nothing to be sequenced against — which is the reason
// it is a page reachable from anywhere rather than a panel inside one view.
// Hoisted out of the block below: the export action needs the same record
// the settings page edits, and a controller scoped to the wiring block
// would be gone by the time that callback is built.
let settings = settings_ui::SettingsController::new();
{
// What the cache actually holds, so the ceiling above it is a figure
// the user can judge rather than an abstract one.
settings.set_usage_label(describe_cache_usage(&library));
// Rendered once up front so the page is correct the first time it is
// opened, rather than on the second open after a callback has run.
settings_ui::render(&window, &settings);
// The export button carries its destination, so it has to be correct
// before the first click rather than after the first settings edit.
refresh_export_label(&window, &settings);
// Apply what is on disk before anything can use it. Without this the
// controller's defaults stand until the user happens to open the
// settings page and change something — so a cache deliberately capped
// at 2 GB last session would spend this one filling to the default.
{
let stored = settings.snapshot();
library.set_cache_budget(stored.cache.original_budget_bytes);
library.set_keep_opened_originals(stored.cache.keep_opened_originals);
}
// --- the export folder picker ----------------------------------------
//
// Wired here rather than inside `settings_ui::wire` because listing a
// remote folder needs credentials, and the settings page deliberately
// holds no session — it is reachable before a library is opened and must
// not depend on one existing.
{
let weak = window.as_weak();
let ctl = settings.clone();
let library = library.clone();
// Every entry point needs the same three things, so they are fetched
// once here rather than at four call sites.
let start = move |ctl: &Rc<settings_ui::SettingsController>,
weak: &slint::Weak<AppWindow>,
library: &Rc<library_ui::LibraryController>,
path: String| {
match library.session() {
Some((creds, session)) => {
settings_ui::spawn_folder_list(
weak.clone(),
ctl.clone(),
creds,
session.user_id.clone(),
path,
);
}
None => {
// No account, so nothing to browse. Said plainly rather
// than left as an empty list, which would read as a server
// with no folders on it.
ctl.set_error("Sign in to a library before choosing a folder on it.");
ctl.browser.replace(None);
}
}
};
{
let (weak, ctl, library, start) =
(weak.clone(), ctl.clone(), library.clone(), start);
window.on_settings_browse_open_picker(move || {
let Some(w) = weak.upgrade() else { return };
// Opens on the library root rather than on whatever the
// destination field happens to contain: a half-typed path
// would list nothing and look like a broken picker.
ctl.browser.replace(Some(launch::FolderBrowser {
path: String::new(),
entries: Vec::new(),
loading: true,
}));
start(&ctl, &weak, &library, String::new());
settings_ui::render(&w, &ctl);
});
}
{
let (weak, ctl, library, start) =
(weak.clone(), ctl.clone(), library.clone(), start);
window.on_settings_browse_into(move |name| {
let Some(w) = weak.upgrade() else { return };
let path = {
let mut browser = ctl.browser.borrow_mut();
let Some(b) = browser.as_mut() else { return };
let path = b.child_path(&name);
b.path = path.clone();
b.entries.clear();
b.loading = true;
path
};
start(&ctl, &weak, &library, path);
settings_ui::render(&w, &ctl);
});
}
{
let (weak, ctl, library, start) =
(weak.clone(), ctl.clone(), library.clone(), start);
window.on_settings_browse_up(move || {
let Some(w) = weak.upgrade() else { return };
let path = {
let mut browser = ctl.browser.borrow_mut();
let Some(b) = browser.as_mut() else { return };
let Some(path) = b.parent_path() else { return };
b.path = path.clone();
b.entries.clear();
b.loading = true;
path
};
start(&ctl, &weak, &library, path);
settings_ui::render(&w, &ctl);
});
}
{
let (weak, ctl) = (weak.clone(), ctl.clone());
window.on_settings_browse_confirm(move || {
let Some(w) = weak.upgrade() else { return };
// The folder being *shown* is the one chosen, matching the
// library picker — so "use this one" means the same thing in
// both places rather than depending on a selection the list
// does not have.
let chosen = ctl.browser.borrow().as_ref().map(|b| b.path.clone());
if let Some(path) = chosen {
ctl.set_destination(path);
}
ctl.browser.replace(None);
settings_ui::render(&w, &ctl);
refresh_export_label(&w, &ctl);
});
}
{
let (weak, ctl) = (weak.clone(), ctl.clone());
window.on_settings_browse_cancel(move || {
let Some(w) = weak.upgrade() else { return };
ctl.browser.replace(None);
settings_ui::render(&w, &ctl);
});
}
}
let lib = library.clone();
let ctl = settings.clone();
let weak = window.as_weak();
settings_ui::wire(&window, settings.clone(), move |s| {
// A ceiling that moved has to be applied to what is already on
// disk, or lowering it would only affect future downloads and the
// cache would sit over budget indefinitely.
lib.set_cache_budget(s.cache.original_budget_bytes);
lib.set_keep_opened_originals(s.cache.keep_opened_originals);
if let Some(w) = weak.upgrade() {
refresh_export_label(&w, &ctl);
}
// Lowering the ceiling evicts, so the figure beside it has just
// changed — leaving the old one would show the cache still over a
// limit that was enforced a moment ago.
ctl.set_usage_label(describe_cache_usage(&lib));
if let Some(w) = weak.upgrade() {
settings_ui::render(&w, &ctl);
}
});
}
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));
// TRACES: FR-DEV-6 | FR-CAT-8
// The settings clipboard, and where the open image's edit is stored.
//
// Both live for the life of the window rather than the view: a copy is
// taken in develop and may be pasted onto a selection back in the grid, so
// a clipboard owned by the develop view would be emptied by the very
// navigation that carries it to its destination.
let clipboard = presets::Clipboard::new();
let open_image: presets::OpenImage = Rc::new(RefCell::new(presets::Stored::Nowhere));
// One model for the lifetime of the window. Rows are mutated in place;
// see `sync_rows` for why replacing it breaks dragging.
let rows: Rc<slint::VecModel<ParamRow>> = Rc::new(slint::VecModel::default());
window.set_adjust_rows(rows.clone().into());
// Viewport size, tracked so a re-render after a slider move matches it.
let viewport = Rc::new(RefCell::new((1024u32, 768u32)));
// Re-render the current session into the canvas.
//
// Called on every slider change, so it must do no more than run the
// adjust pass — the demosaic is not repeated.
let render_now: Render = {
let session = session.clone();
let viewport = viewport.clone();
Rc::new(move |window: &AppWindow, draft: bool| {
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return };
let (mut w, mut h) = *viewport.borrow();
// **Half resolution while the gesture is still moving.**
//
// The adjust pass scales with pixel count, so halving each edge is
// roughly a quarter of the work — the difference between keeping
// up with a drag and lagging behind it. Less dramatic since S1
// removed the readback that scaled the same way and cost far more,
// but a dispatch is still not free at 4K.
// A draft frame is visible for one gesture and is replaced by a
// full-resolution one the moment motion stops, so the cost is a
// little softness exactly while the image is moving too fast to
// study anyway.
if draft {
w = (w / 2).max(1);
h = (h / 2).max(1);
}
// 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());
// Filtering follows the magnification, measured against the
// *full* viewport rather than `w`/`h`: a draft frame is
// rendered at half resolution, and letting that flip the
// canvas to smooth would make it change character for the
// duration of every gesture.
let (vw, vh) = *viewport.borrow();
window.set_magnified(s.magnifies_source(vw, vh));
}
Err(e) => {
log::warn!("render failed: {e}");
window.set_load_error(e.into());
}
}
})
};
// **Rendering is decoupled from input, and this is why.**
//
// A render used to be a blocking GPU round-trip — S1 removed the block,
// but not the reason for this, so read it as history that still applies.
// Running one straight from a `moved` handler put that stall *inside* the
// gesture: touch events arrive far faster than a render completes, so the
// input queue backed up, positions arrived stale, and Android — seeing the
// events go unconsumed — reclaimed the gesture and delivered `cancel`
// instead of `up`. That is the dropped-drag bug, and no amount of tuning
// inside the Slint handlers fixes it while the stall is on the input path.
//
// So `redraw` no longer renders. It marks the canvas dirty and posts a
// single render onto the event loop; every further request while one is
// already pending just sets the flag again. A drag emitting forty events
// therefore renders a handful of times instead of forty, and — the part
// that actually fixes the drop — each event handler returns immediately,
// so the gesture is always consumed promptly.
//
// The flag is re-checked *after* the render because parameters may have
// moved again while it ran; that repost is what keeps the image converging
// on the finger rather than settling on a stale frame.
let render_pending = Rc::new(std::cell::Cell::new(false));
let render_dirty = Rc::new(std::cell::Cell::new(false));
// Set while a settle render is already queued, so a burst of draft frames
// schedules exactly one of them rather than one apiece.
let settle_pending = Rc::new(std::cell::Cell::new(false));
// Whether a request had to be coalesced into one already queued — see the
// gesture note below, where this stands in for a drag boundary.
let was_coalesced = Rc::new(std::cell::Cell::new(false));
let redraw: Rc<dyn Fn(&AppWindow)> = {
let render_now = render_now.clone();
let render_pending = render_pending.clone();
let render_dirty = render_dirty.clone();
let settle_pending = settle_pending.clone();
let was_coalesced = was_coalesced.clone();
Rc::new(move |window: &AppWindow| {
render_dirty.set(true);
if render_pending.get() {
was_coalesced.set(true);
return;
}
render_pending.set(true);
let weak = window.as_weak();
let render_now = render_now.clone();
let render_pending = render_pending.clone();
let render_dirty = render_dirty.clone();
let settle_pending = settle_pending.clone();
let was_coalesced = was_coalesced.clone();
// A zero-delay `Timer` rather than `invoke_from_event_loop`: the
// latter demands `Send`, and every piece of state here is `Rc` on
// the UI thread by design. The delay being zero is the point — this
// is "after the queued input has drained", not a throttle.
slint::Timer::single_shot(std::time::Duration::ZERO, move || {
render_pending.set(false);
let Some(window) = weak.upgrade() else { return };
if !render_dirty.replace(false) {
return;
}
// **What counts as "still dragging".**
//
// No control reports a gesture boundary, and threading one out
// of every slider, curve point and crop handle would be a lot
// of surface for a rendering concern. `was_coalesced` answers
// it instead: it is set only when a request arrived while a
// render was already queued, which can only mean a control
// moved again — that is a drag. One-off changes — a click, a
// reset, a resize — never coalesce, so they render sharp the
// first time and never draw a draft frame at all.
let dragging = was_coalesced.replace(false);
render_now(&window, dragging);
if !dragging {
return;
}
if settle_pending.replace(true) {
return;
}
let weak = window.as_weak();
let render_now = render_now.clone();
let settle_pending = settle_pending.clone();
// Long enough that an ordinary drag never reaches it, short
// enough that the sharp frame feels immediate on release.
slint::Timer::single_shot(SETTLE_DELAY, move || {
settle_pending.set(false);
let Some(window) = weak.upgrade() else { return };
render_now(&window, false);
});
});
})
};
let show = {
let entries = entries.clone();
let index = index.clone();
let session = session.clone();
let redraw = redraw.clone();
let gpu = gpu.clone();
let rows = rows.clone();
let open_image = open_image.clone();
let library_for_show = library.clone();
Rc::new(move |window: &AppWindow| {
// The image about to be replaced is the last chance to persist its
// edit — stepping to the next frame is as much a departure as
// going back to the grid.
presets::save_open_edit(window, &open_image.borrow(), &session, &library_for_show);
let i = *index.borrow();
// 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);
match load(gpu.as_ref(), path) {
Ok(l) => {
window.set_load_error("".into());
window.set_camera(describe_camera(&l.meta).into());
window.set_exposure(describe_exposure(&l.meta).into());
window.set_dimensions(format!("{} × {}", l.width, l.height).into());
// The panel is built from what the pipeline reports, so
// this code names no operation (FR-DEV-3a).
match l.session {
Some(s) => {
window.set_adjust_enabled(true);
*session.borrow_mut() = Some(s);
// A local file stores its edit beside itself. Read
// *before* the first render, so an edited
// photograph never flashes up at its defaults.
*open_image.borrow_mut() = presets::Stored::Local(path.to_path_buf());
if let Some(sidecar) = presets::load_local(path) {
presets::apply_stored_edit(window, &sidecar, &session, &rows);
}
// Through `sync_rows` rather than setting rows
// directly, so the curve's drawn shape is
// refreshed by the same path that refreshes the
// controls — one place to keep them in step.
sync_rows(window, &rows, &session);
redraw(window);
}
None => {
// No sensor data: show the preview and disable
// the controls rather than offering sliders that
// would do nothing.
*session.borrow_mut() = None;
*open_image.borrow_mut() = presets::Stored::Nowhere;
rows.set_vec(Vec::<ParamRow>::new());
window.set_adjust_enabled(false);
if let Some(image) = l.fallback {
window.set_canvas(image);
}
}
}
log::info!("{name}: {}×{}", l.width, l.height);
}
Err(e) => {
// A failure on one image must not stop browsing (FR-RAW-4).
log::warn!("{name}: {e}");
*session.borrow_mut() = None;
window.set_adjust_enabled(false);
window.set_load_error(e.into());
window.set_camera("".into());
window.set_exposure("".into());
window.set_dimensions("".into());
}
}
})
};
// 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();
let activity = activity.clone();
let open_image = open_image.clone();
*open_from_library.borrow_mut() = Some(Rc::new(move |path: String| {
let Some(w) = weak.upgrade() else { return };
// Whatever was open before is being replaced; persist its edit
// before the identity below is overwritten.
presets::save_open_edit(&w, &open_image.borrow(), &session, &library);
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;
};
// TRACES: FR-CAT-8 | FR-NC-8
// Where this image's edit belongs. The uuid comes from the catalog
// so every device names the same version; without one there is
// nowhere to save to, and the image opens read-only as far as
// persistence is concerned rather than writing to an invented
// identity that would never merge.
*open_image.borrow_mut() = match library.version_uuid_for_path(&path) {
Some(version_uuid) => presets::Stored::Remote {
path: path.clone(),
version_uuid,
},
None => {
log::debug!("no catalog version for {path}; edits will not persist");
presets::Stored::Nowhere
}
};
// The sidecar is fetched alongside the image rather than after it.
// It is a few kilobytes against tens of megabytes, so it costs
// nothing to have in hand by the time there is a session to apply
// it to — and starting it here means the edit is ready when the
// photograph is, instead of the image appearing at its defaults
// and visibly changing a moment later.
let sidecar_rx = library::spawn_sidecar_fetch(
creds.clone(),
user_id.clone(),
path.clone(),
library.sidecar_cache_dir().unwrap_or_default(),
library.is_offline(),
);
// TRACES: FR-NC-6a
// The cache is consulted first, so a second open of the same
// photograph is a disk read rather than a second download of tens
// of megabytes — and so a session's worth of images stays
// openable when the connection goes.
let cache = library.cache_context(&path);
if cache.is_none() {
log::debug!("no originals cache for {path}; fetching every time");
}
log::info!("fetching {path} for develop");
w.set_load_error("Downloading…".into());
let rx = library::spawn_full_fetch(creds, user_id, path.clone(), cache);
// The one transfer the user is actively waiting on. It gets a row
// like any other, so a download that is still running after they
// give up and go back to the grid is still accounted for.
//
// No denominator: `spawn_full_fetch` reports a result, not bytes as
// they arrive, so the honest bar here is the indeterminate one.
let job = activity.begin(activity::Kind::Download, format!("Downloading {name}"));
// 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 sidecar_rx = Rc::new(sidecar_rx);
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) => {
job.fail(e.message.clone());
log::warn!("{name}: {e}");
// Offline needs its own words. "network error:
// connection refused" over a photograph the user
// just clicked reads as a broken app; the real
// situation is that this particular image was
// never stored on this device, and the fix is to
// download it while there is a connection.
w.set_load_error(if e.offline {
"Offline — this image is not stored on this device.".into()
} else {
slint::SharedString::from(e.message)
});
return;
}
};
job.finish(activity::describe_bytes(bytes.len() as u64));
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);
// TRACES: FR-CAT-8
// The stored edit, if it has landed. It
// was started before the download of a
// file thousands of times its size, so in
// practice it has; `apply_when_ready`
// covers the case where it has not rather
// than blocking the UI thread on a socket.
apply_when_ready(
&w,
sidecar_rx.clone(),
&session,
&rows,
&redraw,
);
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());
}
}
},
);
}));
}
// ---- copying settings between photographs (FR-DEV-6) ----------------
//
// Wired after the develop session and the library both exist, because a
// paste reaches both: onto the image on screen, or onto the grid's
// selection through its sidecars.
{
presets::wire(
&window,
clipboard.clone(),
presets::Develop {
session: session.clone(),
rows: rows.clone(),
redraw: redraw.clone(),
open: open_image.clone(),
},
settings.clone(),
library.clone(),
collections.clone(),
);
// Close the knot left open beside `open_from_library`: the grid's
// "‹ Library" button was wired before there was a session to save.
let weak = window.as_weak();
let open_image = open_image.clone();
let session = session.clone();
let library = library.clone();
*leave_develop.borrow_mut() = Some(Rc::new(move || {
let Some(w) = weak.upgrade() else { return };
presets::save_open_edit(&w, &open_image.borrow(), &session, &library);
}));
presets::render(&window, &clipboard, &settings);
}
// ---- Adjustment callbacks ------------------------------------------
//
// Generic by construction: they carry indices into the capability list,
// so adding an operation needs no change here (FR-DEV-3c).
// --- export ---------------------------------------------------------
//
// Renders its own frame at full resolution rather than encoding what is
// on screen: the display render is deliberately viewport-sized
// (FR-DSP-1), and exporting that would hand the user a soft, screen-sized
// file with no indication anything had been lost (FR-EXP-9).
{
let weak = window.as_weak();
let session = session.clone();
let settings = settings.clone();
let library = library.clone();
window.on_export_image(move || {
let Some(w) = weak.upgrade() else { return };
w.set_export_busy(true);
// Pushed before the work rather than after: the render blocks the
// UI thread, so a label set afterwards would never be drawn in
// the "Exporting…" state at all.
w.set_export_status("Rendering…".into());
let result = export_now(&w, &session, &settings, &library);
match result {
Ok(placed) => w.set_export_status(placed.describe().into()),
Err(e) => w.set_export_status(format!("Export failed: {e}").into()),
}
w.set_export_busy(false);
refresh_export_label(&w, &settings);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_param_changed(move |op, param, value| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_param(op, param, value);
}
sync_rows(&w, &rows, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_param_reset(move |op, param| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.reset_param(op, param);
}
sync_rows(&w, &rows, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_reset_all(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.reset_all();
}
sync_rows(&w, &rows, &session);
redraw(&w);
});
}
{
// A curve is one control spanning many parameters, so resetting it
// clears all of them at once — resetting a single point would leave
// a shape the user did not ask for.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_curve_reset(move |op| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.reset_curve(op);
}
sync_rows(&w, &rows, &session);
redraw(&w);
});
}
// ---- 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);
w.set_framing_modified(s.framing_edits_image());
}
redraw(&w);
});
}
// ---- rotation, flips and straightening -------------------------------
//
// Framing edits, so unlike zoom and pan they mark the image modified — but
// they are reached through named session actions rather than through a row
// index, so they do not `sync_rows` either. `sync_framing` is what carries
// the applied value back.
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_rotate_quarters(move |turns| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.rotate_quarters(turns);
}
sync_framing(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_flip_h_toggled(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.toggle_flip_h();
}
sync_framing(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_flip_v_toggled(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.toggle_flip_v();
}
sync_framing(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_straighten_changed(move |degrees| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_angle(degrees);
}
sync_framing(&w, &session);
redraw(&w);
});
}
{
// The geometry section's reset: crop, angle, rotation and flips back
// to neutral, leaving every colour adjustment where it is. The panel's
// own reset-all is the one that clears everything.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_framing_reset(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.reset_framing();
}
sync_framing(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let index = index.clone();
let entries = entries.clone();
let show = show.clone();
window.on_next_image(move || {
let Some(w) = weak.upgrade() else { return };
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) % len
};
*index.borrow_mut() = next;
show(&w);
});
}
{
let weak = window.as_weak();
let index = index.clone();
let entries = entries.clone();
let show = show.clone();
window.on_prev_image(move || {
let Some(w) = weak.upgrade() else { return };
let len = entries.borrow().len();
if len == 0 {
return;
}
let prev = {
let cur = *index.borrow();
if cur == 0 {
len - 1
} else {
cur - 1
}
};
*index.borrow_mut() = prev;
show(&w);
});
}
// Track the canvas size so the adjust pass renders at viewport
// resolution rather than sensor resolution (FR-DSP-1).
{
let weak = window.as_weak();
let viewport = viewport.clone();
let redraw = redraw.clone();
window.on_canvas_resized(move |w_px, h_px| {
let Some(w) = weak.upgrade() else { return };
let size = (w_px.max(1) as u32, h_px.max(1) as u32);
if *viewport.borrow() == size {
return;
}
*viewport.borrow_mut() = size;
redraw(&w);
});
}
// FR-UI-1: layout class from window width. Computed here rather than in
// Slint because a property that both derives from and feeds the layout is
// a binding loop.
let panels = std::rc::Rc::new(PanelChoices::default());
{
let weak = window.as_weak();
let panels = panels.clone();
window.on_window_resized(move |width| {
let Some(window) = weak.upgrade() else { return };
apply_layout_class(&window, width, &panels);
});
}
{
let size = window.window().size();
let scale = window.window().scale_factor().max(0.01);
apply_layout_class(&window, size.width as f32 / scale, &panels);
}
// FR-UI-2: the two collapsible columns, opened and closed by hand.
{
let weak = window.as_weak();
let panels = panels.clone();
window.on_toggle_panel(move || {
let Some(w) = weak.upgrade() else { return };
let open = !w.get_panel_visible();
panels.panel.set(Some(open));
w.set_panel_visible(open);
});
}
{
let weak = window.as_weak();
let panels = panels.clone();
window.on_toggle_collections(move || {
let Some(w) = weak.upgrade() else { return };
let open = !w.get_collections_visible();
panels.collections.set(Some(open));
w.set_collections_visible(open);
});
}
// Android's back gesture, and Escape on a keyboard.
{
let weak = window.as_weak();
window.on_back_requested(move || {
let Some(w) = weak.upgrade() else {
return false;
};
back_one_step(&w)
});
}
if !entries.borrow().is_empty() {
show(&window);
}
window.run()?;
Ok(())
}
/// TRACES: FR-NC-6a
/// What the originals cache is holding, for the settings page.
///
/// Pinned and passive are reported separately because they answer different
/// questions: the passive figure is what the budget above it governs, while
/// the pinned figure is disk the user asked for and no ceiling will reclaim.
/// One combined number would make the budget look wrong whenever a large
/// collection was pinned.
fn describe_cache_usage(library: &Rc<library_ui::LibraryController>) -> String {
let Some(cache) = library.cache() else {
return String::new();
};
let catalog = library.catalog();
let borrow = catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return String::new();
};
let Ok(usage) = cache.usage(catalog.connection()) else {
return String::new();
};
let gb = |b: u64| b as f64 / 1_073_741_824.0;
match (usage.passive_count, usage.pinned_count) {
(0, 0) => "Nothing cached yet".to_string(),
(_, 0) => format!(
"{:.1} GB cached ({} images)",
gb(usage.passive_bytes),
usage.passive_count
),
(0, _) => format!(
"{:.1} GB pinned ({} images)",
gb(usage.pinned_bytes),
usage.pinned_count
),
_ => format!(
"{:.1} GB cached ({} images) · {:.1} GB pinned ({} images)",
gb(usage.passive_bytes),
usage.passive_count,
gb(usage.pinned_bytes),
usage.pinned_count
),
}
}
fn describe_camera(m: &Metadata) -> String {
match (&m.make, &m.model) {
(Some(make), Some(model)) => {
// Model often repeats the make; "Canon Canon EOS 6D" reads badly.
if model.starts_with(make.as_str()) {
model.trim().to_string()
} else {
format!("{} {}", make.trim(), model.trim())
}
}
(_, Some(model)) => model.trim().to_string(),
(Some(make), _) => make.trim().to_string(),
_ => String::new(),
}
}
fn describe_exposure(m: &Metadata) -> String {
let mut parts = Vec::new();
if let Some(s) = m.shutter {
// Photographers read fractions, not decimals.
parts.push(if s >= 1.0 {
format!("{s:.1}s")
} else {
format!("1/{}", (1.0 / s).round() as u32)
});
}
if let Some(a) = m.aperture {
parts.push(format!("f/{a:.1}"));
}
if let Some(iso) = m.iso {
parts.push(format!("ISO {iso}"));
}
if let Some(f) = m.focal_length {
parts.push(format!("{f:.0}mm"));
}
parts.join(" ")
}
/// TRACES: FR-UI-1
/// Which collapsible columns the user has opened or closed by hand.
///
/// The layout class supplies each panel's default; this records where the user
/// disagreed, so a panel closed to see more of a photograph stays closed while
/// the window keeps its shape.
///
/// `class` is what makes that "while": a choice is remembered *within* a layout
/// class and dropped when the class changes. Rotating a tablet into portrait
/// asks a different question from the one answered in landscape, and carrying
/// the landscape answer across is how a user ends up with 232px of sidebar on a
/// screen that has no room for it and no memory of having asked.
#[derive(Default)]
struct PanelChoices {
class: std::cell::Cell<Option<bool>>,
panel: std::cell::Cell<Option<bool>>,
collections: std::cell::Cell<Option<bool>>,
}
fn apply_layout_class(window: &AppWindow, width: f32, panels: &PanelChoices) {
let expanded = width >= EXPANDED_MIN_WIDTH;
window.set_expanded(expanded);
window.set_layout_class(if expanded { "expanded" } else { "compact" }.into());
if panels.class.get() != Some(expanded) {
panels.class.set(Some(expanded));
panels.panel.set(None);
panels.collections.set(None);
}
window.set_panel_visible(panels.panel.get().unwrap_or(expanded));
window.set_collections_visible(panels.collections.get().unwrap_or(expanded));
}
/// TRACES: FR-UI-5
/// One step back, and whether there was one to take.
///
/// The Escape half is FR-UI-5's "keyboard shortcuts cover navigation". The
/// Android back gesture answers to the same handler and has no numbered
/// requirement of its own — the register was written before phones and tablets
/// had a platform section, and §1.3 still lists no navigation requirement.
///
/// This is what Android's back gesture and the Escape key both resolve to. The
/// order is the order the states were entered in, innermost first: a mode
/// within a view is left before the view is, because that is what the user
/// most recently did and so what they most likely mean to undo.
///
/// Returning `false` means this is the top of the stack. The shell passes that
/// straight back to the platform as an unhandled key, which on Android closes
/// the activity — the behaviour every application there has, and the reason
/// this answers with a bool rather than swallowing the gesture.
fn back_one_step(w: &AppWindow) -> bool {
let state = NavState {
settings: w.get_show_settings(),
launch: w.get_show_launch(),
browsing: w.get_launch_browsing(),
library: w.get_show_library(),
crop: w.get_crop_mode(),
zoomed: w.get_zoomed(),
// Files named on the command line have no grid behind them — the same
// condition the status strip uses to decide whether to offer the way
// back at all.
has_grid: w.get_library_total() > 0,
scoped: w.get_collection_selected() != 0,
};
let Some(step) = back_step(state) else {
return false;
};
match step {
BackStep::CloseSettings => w.invoke_settings_close(),
BackStep::CancelBrowse => w.invoke_launch_browse_cancel(),
BackStep::LeaveCrop => w.invoke_crop_mode_toggled(false),
BackStep::ResetZoom => w.invoke_zoom_reset(),
BackStep::ToLibrary => w.invoke_back_to_library(),
BackStep::ClearScope => w.invoke_collection_select(0),
}
true
}
/// Where the interface is, as far as going back is concerned.
///
/// A flat snapshot rather than the window itself, so the ordering below can be
/// stated and tested without a Slint backend: which of two states is left first
/// is the whole of this feature, and it is the part that is easy to get subtly
/// wrong when it is spelled out in nested `if`s over live properties.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct NavState {
settings: bool,
launch: bool,
browsing: bool,
library: bool,
crop: bool,
zoomed: bool,
has_grid: bool,
scoped: bool,
}
/// What one step back does, or `None` at the top of the stack.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BackStep {
CloseSettings,
CancelBrowse,
LeaveCrop,
ResetZoom,
ToLibrary,
ClearScope,
}
fn back_step(s: NavState) -> Option<BackStep> {
// Settings is drawn over everything, so it is left first whatever is
// behind it.
if s.settings {
return Some(BackStep::CloseSettings);
}
if s.launch {
// The folder picker is a step inside the launch screen; the launch
// screen itself is where the application starts and has nothing behind.
return s.browsing.then_some(BackStep::CancelBrowse);
}
if !s.library {
// Develop. Crop is a mode and zoom is a view state; both are left
// before the image is.
if s.crop {
return Some(BackStep::LeaveCrop);
}
if s.zoomed {
return Some(BackStep::ResetZoom);
}
return s.has_grid.then_some(BackStep::ToLibrary);
}
// The grid. A collection scoping it is a step in: back widens to the whole
// library before it considers leaving.
//
// And it does not leave: the grid is home, so back from here closes the
// application as it does in every other Android app. Signing out is a
// deliberate act reached from "Change library", not somewhere a stray swipe
// should land.
s.scoped.then_some(BackStep::ClearScope)
}
#[cfg(test)]
mod tests {
use super::*;
/// Develop with a grid behind it — the state most of the back tests vary.
fn developing() -> NavState {
NavState {
has_grid: true,
..NavState::default()
}
}
#[test]
fn back_closes_settings_before_anything_underneath_it() {
// Settings is reachable from both the grid and develop, and is drawn
// over whichever it was opened from. Whatever is behind must wait.
let from_grid = NavState {
settings: true,
library: true,
scoped: true,
..developing()
};
assert_eq!(back_step(from_grid), Some(BackStep::CloseSettings));
let from_develop = NavState {
settings: true,
crop: true,
..developing()
};
assert_eq!(back_step(from_develop), Some(BackStep::CloseSettings));
}
#[test]
fn back_leaves_a_mode_before_it_leaves_the_image() {
// Crop then zoom then the view: innermost first, because that is the
// order they were entered in.
let cropping = NavState {
crop: true,
zoomed: true,
..developing()
};
assert_eq!(back_step(cropping), Some(BackStep::LeaveCrop));
let zoomed = NavState {
zoomed: true,
..developing()
};
assert_eq!(back_step(zoomed), Some(BackStep::ResetZoom));
assert_eq!(back_step(developing()), Some(BackStep::ToLibrary));
}
#[test]
fn back_from_an_image_with_no_grid_behind_it_is_the_top_of_the_stack() {
// Files named on the command line: there is no library to return to,
// and the status strip does not offer one either.
let standalone = NavState {
has_grid: false,
..developing()
};
assert_eq!(back_step(standalone), None);
}
#[test]
fn back_widens_a_scoped_grid_before_it_would_leave_the_grid() {
let scoped = NavState {
library: true,
scoped: true,
has_grid: true,
..Default::default()
};
assert_eq!(back_step(scoped), Some(BackStep::ClearScope));
}
#[test]
fn back_from_the_whole_grid_closes_the_application() {
// The grid is home. Nothing here may navigate to the launch screen:
// that is where signing out lives, and a stray back swipe must not
// land on it.
let home = NavState {
library: true,
has_grid: true,
..Default::default()
};
assert_eq!(back_step(home), None);
}
#[test]
fn back_cancels_the_folder_picker_but_never_leaves_the_launch_screen() {
let picking = NavState {
launch: true,
browsing: true,
..Default::default()
};
assert_eq!(back_step(picking), Some(BackStep::CancelBrowse));
let launch = NavState {
launch: true,
..Default::default()
};
assert_eq!(back_step(launch), None);
}
fn meta() -> Metadata {
Metadata {
make: Some("Canon".into()),
model: Some("Canon EOS 6D".into()),
shutter: Some(1.0 / 250.0),
aperture: Some(2.8),
iso: Some(400),
focal_length: Some(50.0),
..Default::default()
}
}
#[test]
fn camera_does_not_repeat_the_make() {
// rawler reports make "Canon" and model "Canon EOS 6D"; naive
// concatenation gives "Canon Canon EOS 6D".
assert_eq!(describe_camera(&meta()), "Canon EOS 6D");
}
#[test]
fn camera_joins_when_model_omits_the_make() {
let m = Metadata {
make: Some("NIKON".into()),
model: Some("D850".into()),
..Default::default()
};
assert_eq!(describe_camera(&m), "NIKON D850");
}
#[test]
fn missing_camera_metadata_is_empty_not_a_placeholder() {
assert_eq!(describe_camera(&Metadata::default()), "");
}
#[test]
fn shutter_reads_as_a_fraction_below_one_second() {
assert!(describe_exposure(&meta()).starts_with("1/250"));
}
#[test]
fn long_exposures_read_as_seconds() {
let m = Metadata {
shutter: Some(2.5),
..Default::default()
};
assert_eq!(describe_exposure(&m), "2.5s");
}
#[test]
fn exposure_omits_absent_fields() {
let m = Metadata {
iso: Some(100),
..Default::default()
};
assert_eq!(describe_exposure(&m), "ISO 100");
assert_eq!(describe_exposure(&Metadata::default()), "");
}
#[test]
fn only_supported_extensions_are_collected() {
assert!(is_supported(Path::new("a.CR2")));
assert!(is_supported(Path::new("a.jpg")));
assert!(!is_supported(Path::new("a.txt")));
assert!(!is_supported(Path::new("noextension")));
}
fn points(values: &[f32]) -> slint::ModelRc<f32> {
slint::ModelRc::new(slint::VecModel::from(values.to_vec()))
}
#[test]
fn an_unmoved_curve_reports_no_change() {
// What spares every non-curve drag the 96-sample spline evaluation.
let existing = points(&[0.0, 0.0, 1.0, 1.0]);
let fresh = points(&[0.0, 0.0, 1.0, 1.0]);
assert_eq!(
update_points_in_place(&existing, &fresh),
PointsUpdate::Unchanged
);
}
#[test]
fn a_moved_point_reports_the_change_and_is_written_through() {
use slint::Model as _;
let existing = points(&[0.0, 0.0, 1.0, 1.0]);
let fresh = points(&[0.0, 0.25, 1.0, 1.0]);
assert_eq!(
update_points_in_place(&existing, &fresh),
PointsUpdate::Moved
);
// Written into the *existing* model: keeping its identity is what
// stops the drag's own TouchArea being destroyed mid-gesture.
assert_eq!(existing.row_data(1), Some(0.25));
}
#[test]
fn a_different_point_count_is_incompatible() {
// A different image, so there is no drag to preserve and the caller
// must take the fresh model wholesale.
let existing = points(&[0.0, 0.0]);
let fresh = points(&[0.0, 0.0, 1.0, 1.0]);
assert_eq!(
update_points_in_place(&existing, &fresh),
PointsUpdate::Incompatible
);
}
}