Files
DarkRoom/ui/dr-ui/src/lib.rs
T
dtourolle f12aece07e Make storage pluggable, and prove it with a folder backend
`RemoteBackend` existed from the first release and bought nothing it was
designed for. Seven files in `dr-ui` constructed a `NextcloudBackend`
directly, an account *was* a server URL beside a DAV user id, the local
cache directory was named after a hostname, and the launch screen knew
that signing in meant a browser handshake. The trait was real; the seam
was documentation.

A trait over operations is only a quarter of it. Pluggable storage needs
four things, and this adds the other three:

- **Capabilities** — already there, and the reason the engine can drive
  two backends at the speed each actually runs at.
- **Configuration** — `dr_sync::Account`: where a library lives, in
  whatever form its connector addresses, with no server in it. Loads
  every existing config unchanged (`backend` defaults to `nextcloud`,
  `endpoint` is stored under its historical `server` key), and
  `Account::namespace()` reproduces the old catalog directory byte for
  byte, because changing it would abandon a catalog, its thumbnail
  shards, and the sidecars holding unsynced offline work.
- **Registration** — `BackendProvider` and `BackendRegistry`.
  `ui/dr-ui/src/remote.rs` is now the only file above `dr-sync` that
  names a connector.

`Connection` (an account plus an optional `Secret`) replaces the
credentials-and-user-id pair that was threaded through fifteen
signatures in an order that could be swapped. `Secret`'s inner string is
reachable only through `expose()` and its `Debug` prints `Secret(***)`,
so the indirect leak — a `{:?}` on anything holding one — no longer
compiles into a leak.

Nextcloud is unchanged and keeps every peculiarity: propagating ETags,
chunked upload v2, `oc:fileid`, the `oc:permissions` probe on a refused
PUT, the 423 retry classification, Login Flow v2. Those are what the
capability model exists to serve, not something to hide.

`dr-sync-folder` is the second connector: a local disk, a network mount,
an external drive, or a folder a Nextcloud client already syncs. No
account, no credential — the route that works where no secrets daemon
does. It declares `LocalEtags` rather than claiming propagation a POSIX
directory cannot provide, which costs nothing because 50k `stat` calls
are not 50k PROPFINDs. Identity is a path hash, not an inode: an inode
survives a rename but differs between devices and is reused after a
delete, so two machines would disagree about which photograph a
thumbnail belonged to. Re-deriving a thumbnail is a cost; showing the
wrong one is a bug.

docs/storage.md is the contract — the traits, the four steps to add a
backend, and what each connector declares. ARCH §8.0 and §8.4a, and
FR-NC-13, say why.
2026-08-29 09:57:52 +02:00

3179 lines
133 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 display_ui;
mod export;
pub mod faces;
mod gradient;
mod histogram;
pub mod identity;
mod identity_ui;
mod import;
mod import_ui;
mod labels;
mod library;
mod library_ui;
#[cfg(live_style)]
mod live_style;
mod masks_ui;
mod net_runtime;
mod presets;
mod remote;
mod segmentation;
mod settings_store;
mod settings_ui;
mod sidecar_cache;
mod spots_ui;
mod trash;
use std::cell::{Cell, RefCell};
use std::path::{Path, PathBuf};
use std::rc::Rc;
use anyhow::Result;
use dr_decode::{Metadata, PreviewSize};
pub use develop::DevelopSession;
/// Where a model has to land for face indexing to find it, on any account.
///
/// Public for the Android entry point, which is the only caller that knows the
/// APK may carry a bundled copy and has to write it out before any store is
/// opened — see `library::shared_face_models_dir`.
pub use library::shared_face_models_dir;
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;
/// TRACES: FR-UI-2
/// The largest share of the window the develop column may take.
///
/// The column sizes itself to the widest thing it holds — the generated mode
/// strip, the Copy/Paste pair, the histogram's axis labels — so on a rich
/// operation set it would otherwise keep growing. This is where that stops.
/// The majority of the window stays with the photograph, which is what the
/// column is there to serve.
const PANEL_MAX_FRACTION: f32 = 0.45;
/// The floor under that share, so a narrow window still gets a usable column
/// rather than one squeezed below the width its own controls were drawn for.
/// Matches the 280px the column asks for at minimum: below this the cap would
/// be doing the clipping the cap exists to avoid.
const PANEL_MIN_WIDTH: f32 = 280.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();
// 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 {
match open_session(ctx, bytes, orientation) {
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,
})
}
/// TRACES: FR-EXP-7 | FR-RAW-4
/// Open bytes for editing, with no interface types involved.
///
/// Split out of [`load_bytes`] so the batch exporter can share it. That runs on
/// a worker thread, where a `slint::Image` has no business being constructed —
/// and a second copy of the JPEG-versus-RAW routing is a second copy that would
/// drift, which is exactly how a batch comes to export something the viewer
/// would have shown differently.
pub(crate) fn open_session(
ctx: &dr_gpu::GpuContext,
bytes: &[u8],
orientation: dr_types::Orientation,
) -> Result<DevelopSession, String> {
// 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.
if dr_decode::probe(bytes) == Some(dr_types::Format::Jpeg) {
return 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)
});
}
// 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))
}
/// 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 the view 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 — or, since local masking became a mode, would
/// open the next photograph with a mask stack it does not have.
fn reset_view_state(window: &AppWindow) {
window.set_view_mode(ViewMode::Photo);
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);
// A photograph that failed to decode has no session, so nothing below
// will speak for it — and the buttons would otherwise keep offering the
// previous image's history.
window.set_can_undo(false);
window.set_can_redo(false);
// TRACES: FR-DSP-7
// Emptied rather than left standing: the previous photograph's histogram
// beside the next one's filename is a confident, precise lie, and the gap
// before the new frame settles is exactly long enough to read it.
window.set_histogram(histogram::empty());
// TRACES: FR-DEV-3
// The region map belongs to one photograph. Carrying the stack, the
// overlay or the crosshair to the next one would offer a selection of
// regions that are not in the picture on screen.
masks_ui::reset(window);
// TRACES: FR-DEV-8
// The repairs belong to one photograph too. The next one's arrive with its
// sidecar a moment later, and until they do the canvas must not be showing
// the last one's.
spots_ui::reset(window);
}
/// 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 | FR-EXP-7
/// Render the open image at full resolution, ready to be handed to the worker.
///
/// The render stays here, on the UI thread, and everything after it does not.
/// That split is deliberate rather than the remains of the synchronous version
/// this replaced: the frame belongs to the `DevelopSession` the interface owns,
/// and the edit in it may not have reached a sidecar yet, so a worker that
/// re-opened the photograph for itself would export the *saved* version rather
/// than the one on screen.
///
/// What is left on this thread is a GPU pass and a readback. The Lanczos
/// reduction, the encode and the write — the larger half of the wait, and all
/// of its variance — leave with the frame.
fn render_open_frame(
window: &AppWindow,
session: &Rc<RefCell<Option<DevelopSession>>>,
space: dr_types::ColourSpace,
) -> Result<export::Source, String> {
let mut borrowed = session.borrow_mut();
let Some(session) = borrowed.as_mut() else {
return Err("nothing is open".into());
};
// The colour space is chosen at render time because the conversion happens
// in the shader, before the clip to 0..1 — see `render_for_export`.
let frame = session.render_for_export(space)?;
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());
Ok(export::Source::Rendered { stem, frame })
}
/// TRACES: FR-EXP-7
/// Everything a batch needs that only the UI thread can assemble.
fn batch_request(
sources: Vec<export::Source>,
settings: &Rc<settings_ui::SettingsController>,
library: &Rc<library_ui::LibraryController>,
gpu: Option<&dr_gpu::GpuContext>,
) -> export::BatchRequest {
let stored = settings.snapshot();
export::BatchRequest {
sources,
conn: library.credentials(),
settings: stored.export,
outbox: match library.session() {
Some(c) => export::outbox_dir(&c.account),
// No account, so no outbox — a device export still works, and a
// remote one is refused by `place` rather than here, so the message
// names the setting rather than the plumbing.
None => std::env::temp_dir().join("darkroom-outbox"),
},
sidecar_cache: library.sidecar_cache_dir().unwrap_or_default(),
offline: library.is_offline(),
gpu: gpu.cloned(),
}
}
/// TRACES: FR-EXP-7 | FR-NC-10
/// Send anything waiting in the outbox to the server.
///
/// Called after an export and again on every sync pass. Both, deliberately:
/// the first is what makes an upload feel immediate, and the second is what
/// eventually delivers the exports made while the train was in a tunnel.
/// Running it twice over an empty outbox costs a directory listing.
fn drain_outbox(library: &Rc<library_ui::LibraryController>) {
// Offline is not a failure worth reporting here — the entries stay
// staged and the next pass takes them.
if library.is_offline() {
return;
}
let Some(conn) = library.session() else {
return;
};
let outbox = export::outbox_dir(&conn.account);
if export::pending_count(&outbox) == 0 {
return;
}
let root = conn.account.root.clone();
let rx = export::spawn_upload(conn, root, outbox);
std::thread::spawn(move || {
while let Ok(msg) = rx.recv() {
match msg {
export::UploadMessage::Status(s) => log::info!("export: {s}"),
export::UploadMessage::Finished {
uploaded,
remaining,
error,
} => {
log::info!("export: {uploaded} uploaded, {remaining} still queued");
if let Some(e) = error {
log::warn!("export upload stopped: {e}");
}
}
}
}
});
}
/// 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 remote = stored.export.target == dr_types::ExportTarget::Remote;
window.set_export_label(
if remote {
// Not the connector's name: this is a Nextcloud account for some
// libraries and a folder on a mount for others.
"Export to the library"
} else {
"Export"
}
.into(),
);
// The grid's button says the same thing about a selection, but it composes
// its own label around a count — so it is given the fact rather than the
// sentence (FR-EXP-7).
window.set_export_to_server(remote);
}
/// 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);
}
},
);
}
/// TRACES: FR-DEV-3f
/// Push the film choice out to the panel.
///
/// Separate from [`sync_rows`] because the stock is not a row: it is not a
/// parameter, so it is not in the model the panel generates its controls from.
/// Called from the two handlers that change it, and once when an image opens.
pub(crate) fn sync_film(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>>) {
let choices = DevelopSession::film_choices();
let chosen = session
.borrow()
.as_ref()
.and_then(|s| s.film().map(|(stock, print)| (stock.to_string(), print)));
// A stock this build has no profile for leaves the picker on "None" rather
// than inventing an entry for it. The sidecar still carries the name — it
// is only the *control* that cannot show what it does not have.
let selected = chosen
.as_ref()
.and_then(|(stock, _)| {
choices
.iter()
.position(|(id, _)| *id == Some(stock.as_str()))
})
.unwrap_or(0);
let can_print = choices
.get(selected)
.and_then(|(id, _)| *id)
.and_then(dr_film::find)
.and_then(dr_film::default_print)
.is_some();
window.set_film_stocks(slint::ModelRc::new(slint::VecModel::from(
choices
.into_iter()
.map(|(_, name)| slint::SharedString::from(name))
.collect::<Vec<_>>(),
)));
window.set_film_selected(selected as i32);
window.set_film_can_print(can_print);
window.set_film_print(chosen.map(|(_, print)| print).unwrap_or(false));
}
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);
// TRACES: FR-DEV-3f
// The film, for the same reason and by the same argument: it is not a row
// — a stock is not a parameter — but it is panel state that has to stay in
// step, and putting it here means every path that refreshes the controls
// refreshes the picker. Doing it at the *call sites* is what left the
// control invisible until something else happened to sync it: the list is
// populated here, and a panel opened without this has no stocks to offer.
sync_film(window, session);
// TRACES: FR-DEV-3a
// The groups, derived from what the operations say they are about. This
// side resolves the labels; nothing here or in `adjust.slint` names an
// operation or a group.
let (tabs, active_tab) = match session.borrow().as_ref() {
Some(s) => (
s.tabs().into_iter().map(|(_, name)| name.into()).collect(),
s.active_tab(),
),
None => (Vec::<slint::SharedString>::new(), -1),
};
window.set_adjust_tabs(slint::ModelRc::new(slint::VecModel::from(tabs)));
window.set_adjust_active_tab(active_tab);
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;
// The curves the widget can switch between, named. They can only
// change with the operation set, which is what this branch means, so
// the walk that derives them is not on the parameter-event path.
let channels: Vec<slint::SharedString> = match session.borrow().as_ref() {
Some(s) => s.curve_channels().into_iter().map(Into::into).collect(),
None => Vec::new(),
};
window.set_curve_channels(slint::ModelRc::new(slint::VecModel::from(channels)));
}
// Which curve is plotted, on every pass. Picking one that happens to be
// shaped like the last — two untouched curves are both the diagonal —
// moves no point, so this cannot ride on the resample below: the chips
// would go on highlighting the curve the user just navigated away from.
let channel = session.borrow().as_ref().map_or(0, |s| s.curve_channel());
window.set_curve_channel(channel);
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.
#[cfg(not(target_os = "android"))]
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: FR-DSP-1 | AC-8
/// Open the compute device on Android — and do **not** give it to Slint.
///
/// # Why this platform is different
///
/// The desktop version above exists so one `wgpu::Texture` can be written by
/// the adjust pass and sampled by the compositor without a round-trip. That
/// requires Slint to be drawing with wgpu, and on Android drawing with wgpu
/// means drawing on wgpu's Vulkan swapchain — which hardcodes
/// `preTransform = IDENTITY` (gfx-rs/wgpu#3345).
///
/// On a tablet whose panel is mounted landscape, portrait then presents an
/// unrotated buffer, every present returns `VK_SUBOPTIMAL_KHR`, and the frames
/// arrive torn. Measured on the device: portrait sits on `composition=CLIENT`
/// with `bufferTransform=ROT_270`, landscape on `composition=DEVICE` with
/// `ROT_180`, and only portrait tore. Taking Slint off wgpu — it then uses
/// Skia over OpenGL, where the driver owns the rotation — fixed it.
///
/// # What it costs, and why that is the right trade here
///
/// Without a shared device the display path needs a readback:
/// `AdjustPass::export_pixels` instead of `Image::try_from(texture)`. That is
/// the transfer ARCH §6.1 and AC-8 exist to avoid, and it is still the better
/// bargain on this platform — the alternative is not a faster develop view, it
/// is a torn one, in the orientation a tablet is mostly held in.
///
/// The compute passes are untouched: demosaic and adjust still run on the GPU,
/// on this device. Only the last hop to the screen goes through memory.
#[cfg(target_os = "android")]
fn shared_gpu() -> Option<dr_gpu::GpuContext> {
match pollster::block_on(dr_gpu::GpuContext::new_headless()) {
Ok(ctx) => Some(ctx),
Err(e) => {
log::warn!("no GPU for the develop passes: {e}");
None
}
}
}
/// 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());
// TRACES: FR-PLAT-LIN-1
// The name the compositor knows this window by, and the reason the
// launcher shows a real icon rather than a grey square.
//
// `app.slint` sets `icon:`, and that is genuinely embedded (see
// `build.rs`) — but a Wayland compositor ignores a client-set icon
// entirely. It takes the icon from the `.desktop` file whose basename
// matches the surface's `app_id`, and nothing else. So the embedded icon
// is what X11 and the window itself use, and *this* is what GNOME's
// overview, dash and alt-tab use. Both are needed and neither substitutes
// for the other.
//
// The string must equal the installed `.desktop` file's basename exactly:
// `packaging/paris.tourolle.darkroom.desktop`. It matches the Android
// package id (`AndroidManifest.xml`) on purpose — one application, one
// reverse-DNS name on both platforms.
//
// Before the window, necessarily: it is read when the surface is created.
if let Err(e) = slint::set_xdg_app_id("paris.tourolle.darkroom") {
// Not fatal. On X11 and on Android this does nothing useful, and a
// missing app id costs an icon rather than a working application.
log::debug!("could not set the xdg app id: {e}");
}
// 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()?;
// The version the About page shows. Taken from the crate rather than
// passed in, so it is the version of the code that is running and cannot
// be set to something else by a caller.
window.set_app_version(env!("CARGO_PKG_VERSION").into());
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());
// The People screen's own state: which person is open, and which of their
// faces are ticked for a split.
let identity = std::rc::Rc::new(identity_ui::IdentityController::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 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_span = 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(),
// What a shift-click selects: the whole run between its two
// ends, read from the catalog rather than from the hundred or
// so rows that happen to be loaded.
move |first, last| lib_span.ids_in_span(first, last),
// The trash's MOVE and DELETE go to the same account the scan
// and thumbnail workers use.
move || lib_session.session(),
);
// The People screen reads the same catalog and the same thumbnail
// store the grid does — its face crops come from the proxies the
// grid already built, which is the whole reason face indexing is
// affordable (FR-CULL-8).
let lib_store = library.clone();
identity_ui::wire(
&window,
identity.clone(),
library.catalog(),
activity.clone(),
move || {
let conn = lib_store.session()?;
dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account))
.ok()
.map(std::rc::Rc::new)
},
// The weights are not shipped and are not a build input
// (docs/faces.md §2): the user puts them beside the catalog,
// and their absence is the ordinary state of a fresh install.
{
let lib = library.clone();
move || {
let conn = lib.session()?;
library::face_models(&conn.account)
}
},
// The sweep opens its own connection on its own thread, so it
// takes paths rather than the handles this screen holds — and
// a connection, because it fetches the pixels it indexes rather
// than reading whatever the grid happened to leave behind.
{
let lib = library.clone();
move || {
let conn = lib.session()?;
let catalog = library::catalog_path(&conn.account);
let thumbs = library::thumbs_dir(&conn.account);
Some((conn, catalog, thumbs))
}
},
);
}
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());
// Before anything opens a store: an upgrade must not
// abandon a catalog, its thumbnails, or the offline
// ratings and edits waiting beside them.
library::migrate_legacy_cache_data(&session);
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();
// TRACES: FR-CAT-10 | FR-CAT-11 | FR-NC-7a | FR-NC-7b
// Import: a card into the library, and on to the server.
//
// Wired after the settings controller exists because it shares it — an
// import's options are stored in the same file as everything else, and two
// controllers each holding their own copy would each save over the other.
//
// The account is fetched afresh inside the closure rather than captured:
// this runs once at startup, where a library can be opened, closed and
// re-opened for the whole life of the window.
{
// Whether the header offers Import at all. A fact about the platform,
// set once: it cannot change while the window is open, and on Android
// it is false because there is no card to reach and nothing to write
// through (`dr_plat::imports_supported`).
window.set_import_supported(dr_plat::imports_supported());
let import = import_ui::ImportController::new(settings.clone(), activity.clone());
let library_for_context = library.clone();
let weak = window.as_weak();
import_ui::wire(
&window,
import,
move || {
let conn = library_for_context.session()?;
Some(import_ui::Context {
catalog: library::catalog_path(&conn.account),
library_label: conn.account.root.clone(),
// The same formats the scan looks for. An import that took
// types the library then ignores would copy files off the
// card that never appear in the grid.
filter: conn.account.format_filter(),
upload: Some(import::Upload {
library: conn.account.root.clone(),
// The same shard store the grid reads and the sync
// pushes, so a thumbnail made during an import is the
// one every other client gets.
thumbs: library::thumbs_dir(&conn.account),
staging: import::staging_dir(&conn.account),
conn,
}),
})
},
move || {
// The import wrote files into a folder on this machine and, if
// the account allowed it, into the library on the server. Only
// the second is what the grid shows, so this asks for the scan
// that finds them rather than inserting rows itself.
if let Some(w) = weak.upgrade() {
w.invoke_library_rescan();
}
},
);
}
{
// 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);
library.set_timeline_bars(stored.library.timeline_bars);
}
// --- 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(conn) => {
settings_ui::spawn_folder_list(weak.clone(), ctl.clone(), conn, 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);
}
// The axis is cut into bars when the window is loaded, so a new
// count only shows once something reloads it. Done here, and only
// when the figure actually moved: a setting that appears to do
// nothing until the user scrolls is one they will change twice and
// then leave wrong.
if lib.set_timeline_bars(s.library.timeline_bars) {
if let Some(w) = weak.upgrade() {
library_ui::reload(&w, &lib);
}
}
// 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);
}
},
{
// Face coverage, read when the page opens. The figures live in the
// catalog and the settings page holds no session, so they arrive
// through here rather than being kept up to date continuously —
// they are only ever looked at while this page is on screen.
let lib = library.clone();
let catalog = library.catalog();
move |w: &AppWindow| {
let store = lib.session().and_then(|c| {
dr_thumbs::ThumbStore::open(&library::thumbs_dir(&c.account)).ok()
});
identity_ui::refresh_coverage(w, &catalog, store.as_ref());
w.set_identity_model_missing(
lib.session()
.and_then(|c| library::face_models(&c.account))
.is_none(),
);
}
},
);
}
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.
//
// In *physical* pixels, which is what the render target wants and what
// FR-DSP-8 means by handling fractional scaling without resampling. The
// one caller that thinks in logical pixels is Slint's resize callback,
// and it converts on the way in.
let viewport = Rc::new(RefCell::new((1024u32, 768u32)));
// TRACES: FR-DSP-8
// What the displays are, and which one the canvas is on. Probed once here
// so that the About page can describe the session's colour path before any
// photograph is opened — the acquisition path is a property of the desktop
// and not of the image.
let display = display_ui::DisplayWatch::probe();
// 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.
// TRACES: FR-DEV-5
// The history revision the panel was last built from. See the use below:
// the list is rebuilt when it would read differently, not when the picture
// is redrawn, and those are very different rates.
let drawn_history: Rc<Cell<Option<u64>>> = Rc::new(Cell::new(None));
let render_now: Render = {
let session = session.clone();
let viewport = viewport.clone();
let drawn_history = drawn_history.clone();
let display = display.clone();
Rc::new(move |window: &AppWindow, draft: bool| {
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return };
// TRACES: FR-DSP-8 | FR-DSP-6
// **Every canvas render is encoded for the display showing it.**
//
// Set here rather than pushed from the display watch, and rather
// than set once when a photograph is opened, because this is the
// one path every frame takes. A session opened while the window
// sits on the second monitor is then correct on its *first* frame
// — where a push would leave it sRGB until the next poll, which is
// a visible flash of the wrong colour on every image opened.
//
// Free when nothing has changed: the field is compared before it
// is written, and an unchanged output space composes to the same
// structure hash and the same cached pipeline.
s.set_display_space(display.space());
// TRACES: FR-DEV-5
// Whether undo has anywhere to go, pushed from here because every
// edit ends in a redraw and nothing else is on all of their paths:
// the parameter callbacks sync rows, the framing ones sync the
// geometry panel, and a paste arrives through neither.
window.set_can_undo(s.can_undo());
window.set_can_redo(s.can_redo());
// TRACES: FR-DEV-5 | FR-DEV-7
// The list, on the same path and for the same reason — but only
// when it would read differently. A drag ends in a redraw per
// frame while folding into one step, so an unconditional rebuild
// here would tear down and recreate every row of the panel sixty
// times a second to arrive back at the list already on screen.
let revision = s.history_revision();
if drawn_history.get() != Some(revision) {
drawn_history.set(Some(revision));
window
.set_history_rows(slint::ModelRc::new(slint::VecModel::from(s.history_rows())));
window.set_undo_label(s.undo_label().into());
}
// TRACES: FR-DEV-3
// Which part of the region overlay the view is showing. Here
// rather than in the panel's own sync because a pan or a zoom
// changes it while changing no mask and no row — and every one of
// those ends in a redraw.
masks_ui::sync_overlay_view(window, s);
// TRACES: FR-DEV-8
// And where the repairs are drawn, for the same reason: a pan or a
// zoom moves every circle while touching no repair, and a circle
// left where the mark used to be is worse than no circle at all.
spots_ui::sync_handles(window, s);
// The column's own numbers, pushed from here as well so that a
// photograph opened with repairs already on it arrives with the
// panel describing them — the sidecar lands after the callbacks
// have all been installed, and a redraw is the one path every
// arrival takes.
spots_ui::sync_panel(window, s);
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_view_mode() == ViewMode::Crop {
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));
// TRACES: FR-DSP-7
// **Counted on the settled frame and no other.**
//
// FR-DSP-7 requires the histogram not extend the FR-DSP-3
// frame budget, and `draft` is already exactly the flag
// that says a gesture is still moving — so this reduction
// and its 4 KB transfer happen once when the slider stops
// rather than on every one of the forty frames a drag
// emits. Nothing is lost by it: a histogram of a
// half-resolution frame flickering past under a finger is
// not a reading anyone takes.
if !draft {
window.set_histogram(
s.histogram()
.as_ref()
.map_or_else(histogram::empty, histogram::view),
);
}
}
Err(e) => {
log::warn!("render failed: {e}");
window.set_load_error(e.into());
// No frame, so nothing to describe. The stale plot would
// otherwise sit beside the error message looking current.
window.set_histogram(histogram::empty());
}
}
})
};
// **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(conn) = 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(
conn.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(conn, 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();
// Cloned for the timer closure: the outer callback is an `Fn` and
// may run again for the next photograph.
let library = library.clone();
let path = path.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(mut s) => {
w.set_adjust_enabled(true);
// TRACES: FR-CULL-10
// Who is in this photograph, so a
// segmentation run can say "Anna" where
// the model can only say "person". Read
// here because this is the one moment the
// catalog and the image id are both in
// reach; every run afterwards gets them
// for free.
if let Some(id) = library.image_id_for_path(&path) {
let cat = library.catalog();
let borrowed = cat.borrow();
if let Some(c) = borrowed.as_ref() {
match crate::identity::named_boxes_normalised(c, id) {
Ok(n) if !n.is_empty() => {
log::info!(
"{} known face(s) in this photograph",
n.len()
);
s.set_face_names(n);
}
Ok(_) => {}
Err(e) => {
log::debug!("reading known faces: {e}")
}
}
}
}
*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 (FR-EXP-6, FR-EXP-7, FR-EXP-9) ---------------------------
//
// Two buttons, one worker. The develop view exports the image on screen and
// the grid exports its selection; the only difference between them is who
// renders the frames, so both hand a `Vec<Source>` to the same batch and
// both report through the same activity row.
//
// A frame is always rendered at full resolution rather than taken from the
// canvas: 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).
{
// Replaced at each start, so the button always cancels the run it is
// sitting on and a stale token cancels nothing.
let cancel: Rc<RefCell<export::Cancel>> = Rc::new(RefCell::new(export::Cancel::default()));
// Holds the drain. Assigning a new timer drops the previous one, which
// is what takes a superseded batch's row out of the register.
let drain: Rc<RefCell<Option<slint::Timer>>> = Rc::new(RefCell::new(None));
let start = {
let settings = settings.clone();
let library = library.clone();
let activity = activity.clone();
let gpu = gpu.clone();
let cancel = cancel.clone();
let drain = drain.clone();
Rc::new(
move |window: &AppWindow, sources: Vec<export::Source>, to: export::Reporting| {
let total = sources.len();
let request = batch_request(sources, &settings, &library, gpu.as_ref());
let token = export::Cancel::default();
*cancel.borrow_mut() = token.clone();
let rx = export::spawn_batch(request, token);
// Upload the moment the batch is done rather than waiting
// for a sync pass. An export bound for the server is
// complete on disk the instant it is staged, but it is not
// where the user asked for it until this runs — and
// "Queued for Exports" sitting unchanged until somebody
// presses Sync reads as an export that did not upload.
let library_for_drain = library.clone();
export::drain_batch(
window.as_weak(),
&activity,
&drain,
rx,
total,
to,
move || drain_outbox(&library_for_drain),
);
},
)
};
{
let weak = window.as_weak();
let session = session.clone();
let settings = settings.clone();
let start = start.clone();
window.on_export_image(move || {
let Some(w) = weak.upgrade() else { return };
// One batch at a time, and the grid's counts as one. The two
// buttons share a worker slot, so starting a second run would
// drop the first's drain — leaving a batch still writing files
// with no progress row and a button that never comes back.
if w.get_export_busy() || w.get_library_exporting() {
w.set_export_status("An export is already running".into());
return;
}
w.set_export_busy(true);
// Set before the render rather than after: the render is the
// one part still on this thread, so a label written afterwards
// would never be drawn in the "Rendering…" state at all.
w.set_export_status("Rendering…".into());
match render_open_frame(&w, &session, settings.snapshot().export.colour_space) {
Ok(source) => start(&w, vec![source], export::Reporting::Develop),
Err(e) => {
w.set_export_status(format!("Export failed: {e}").into());
w.set_export_busy(false);
}
}
refresh_export_label(&w, &settings);
});
}
// TRACES: FR-EXP-7
// The grid's selection. Nothing is rendered here — the worker fetches,
// decodes and renders each photograph itself, so this returns to the
// event loop immediately and a batch of three hundred is a progress bar
// rather than a frozen window.
{
let weak = window.as_weak();
let library = library.clone();
let collections = collections.clone();
let start = start.clone();
window.on_library_export_selection(move || {
let Some(w) = weak.upgrade() else { return };
// See the develop button above for why one run excludes the
// other. The button is a cancel by then, so this only catches
// a batch started from develop and left running.
if w.get_library_exporting() || w.get_export_busy() {
w.set_library_status("An export is already running".into());
return;
}
let sources = library.export_sources(&collections.selected());
if sources.is_empty() {
// Said out loud rather than ignored, matching what a paste
// or a judgement keystroke does with an empty selection.
w.set_library_status("Select an image first".into());
return;
}
w.set_library_exporting(true);
w.set_library_status(format!("Exporting {} images…", sources.len()).into());
start(&w, sources, export::Reporting::Library);
});
}
// TRACES: NFR-ARCH-3
{
let weak = window.as_weak();
window.on_library_cancel_export(move || {
cancel.borrow().cancel();
if let Some(w) = weak.upgrade() {
// The worker stops at the next point it is safe to — which
// may be a frame away — so the button says "asked for" and
// not "done". The drain writes the real answer.
w.set_library_status("Cancelling the export…".into());
}
});
}
}
{
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);
// TRACES: FR-DEV-3f
// The film's own exposures ride *inside* the baked tables
// rather than arriving as uniforms, because the print balance
// is solved against them — an enlarger's filtration depends on
// how the negative was exposed. So moving one has to rebuild
// the lookup, which no other slider in the panel does.
s.rebake_film_if_affected(op);
}
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);
});
}
// TRACES: FR-DEV-3
// The local-adjustment panel. Wired as a block rather than inline because
// it is a dozen callbacks that all say the same three things, and they
// read better beside each other than scattered through this function.
{
// Choosing a group re-filters the panel and nothing else — no edit,
// no render. It is navigation.
let weak = window.as_weak();
let session = session.clone();
let rows = rows.clone();
window.on_adjust_tab_picked(move |index| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_active_tab(index);
}
sync_rows(&w, &rows, &session);
});
}
masks_ui::wire(&window, &session, &rows, &redraw);
spots_ui::wire(&window, &session, redraw.clone());
// A way to land on the Identity Manager at startup, for looking at it
// without a mouse. Off unless the variable is set, so it costs a getenv
// per launch and changes nothing otherwise.
if std::env::var_os("DARKROOM_START_IDENTITY").is_some() {
window.invoke_identity_open();
}
if std::env::var_os("DARKROOM_START_SETTINGS").is_some() {
window.invoke_settings_open();
}
{
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);
});
}
{
// Which of the curve's curves the plot is showing. **No redraw**, and
// that is the whole character of this control: it changes no
// parameter, so the photograph is already correct on screen and
// recomputing it would be a frame spent to produce the same pixels.
// For the same reason it records no history step — there is nothing
// to undo — and the sidecar never hears about it.
let weak = window.as_weak();
let session = session.clone();
let rows = rows.clone();
window.on_curve_channel_picked(move |index| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_curve_channel(index);
}
sync_rows(&w, &rows, &session);
});
}
// ---- the film stock (FR-DEV-3f) -------------------------------------
//
// Unlike the curve channel above, both of these change the photograph:
// choosing a stock *is* the edit. So they redraw, and they leave the
// session dirty for the sidecar in the ordinary way.
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_film_picked(move |index| {
let Some(w) = weak.upgrade() else { return };
// Index zero is "None" — see `DevelopSession::film_choices`, which
// puts it first so that the neutral choice needs no sentinel.
let choices = DevelopSession::film_choices();
let Some((stock, _)) = choices.get(index.max(0) as usize) else {
return;
};
if let Some(s) = session.borrow_mut().as_mut() {
// Printed by default when the stock has a paper: a colour
// negative that has not been printed is an orange strip, and
// offering that as the first thing a photographer sees when
// they pick Portra would read as a bug rather than as a
// choice. The toggle is there for anyone who wants the scan.
let print = stock
.and_then(dr_film::find)
.and_then(dr_film::default_print)
.is_some();
s.pick_film(*stock, print);
}
sync_rows(&w, &rows, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_film_print_toggled(move |print| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
if let Some((stock, _)) = s.film().map(|(a, b)| (a.to_string(), b)) {
s.pick_film(Some(&stock), print);
}
}
sync_film(&w, &session);
redraw(&w);
});
}
// ---- undo and redo (FR-DEV-5) ---------------------------------------
//
// Thin, because the history lives in the session and every mutator there
// records into it — see `DevelopSession::history`. What is left for the
// interface is the refresh a paste also needs: the controls are showing
// values that have just moved underneath them.
//
// `can-undo` and `can-redo` are not set here; `render_now` pushes them on
// every redraw, which is every path that can change them.
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_undo(move || {
let Some(w) = weak.upgrade() else { return };
let stepped = session.borrow_mut().as_mut().is_some_and(|s| s.undo());
if stepped {
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_redo(move || {
let Some(w) = weak.upgrade() else { return };
let stepped = session.borrow_mut().as_mut().is_some_and(|s| s.redo());
if stepped {
sync_rows(&w, &rows, &session);
redraw(&w);
}
});
}
// TRACES: FR-DEV-5 | FR-DEV-7
// Clicking a row. Arriving six steps away costs what arriving from one
// does, because a step is a whole state — see `History::go_to`.
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_history_picked(move |index| {
let Some(w) = weak.upgrade() else { return };
let stepped = session
.borrow_mut()
.as_mut()
.is_some_and(|s| s.go_to_history(index));
if stepped {
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);
});
}
{
// TRACES: FR-UI-5
// **Entering a mode is a side effect, which is why Rust owns it** and
// the strip does not simply write the property. Each of the three has
// work to do that the interface cannot see:
//
// *Crop* 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.
//
// *Local* turns the region overlay on. It used to be a button in the
// masking panel, so the mode could be open with the overlay off —
// which is a mode you have entered that is doing nothing.
//
// *Leaving* clears the selection, and that is the fault this whole
// pass exists for: a selected layer silently re-points thirty sliders
// at that layer's chain, so a mode you have left must not leave one
// behind. After this the controls are unambiguously global again,
// which is what the panel's heading then says.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_mode_picked(move |mode| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
match mode {
ViewMode::Crop => {
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);
}
ViewMode::Local => s.set_overlay(true),
// TRACES: FR-DEV-8
// Nothing to arm: the circles are drawn whenever there are
// repairs, and what the mode changes is whether a click on
// the photograph makes another one. Leaving the mask
// selection behind would re-point the column at a layer's
// chain while the canvas is showing repairs, which is the
// fault this whole strip exists to prevent.
ViewMode::Spots => {
s.set_overlay(false);
s.set_active_mask(None);
}
ViewMode::Photo => {
s.set_overlay(false);
s.set_active_mask(None);
// A repair stays on the photograph; only the *selection*
// goes, so the source circle does not hang about over a
// frame nobody is repairing any more.
s.select_spot(None);
}
}
}
w.set_view_mode(mode);
masks_ui::sync(&w, &session);
if let Some(s) = session.borrow().as_ref() {
spots_ui::sync_handles(&w, s);
}
// The scope may have just changed, so the panel below is now
// describing a different chain.
sync_rows(&w, &rows, &session);
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).
//
// TRACES: FR-DSP-8
// Slint reports the canvas in *logical* pixels, which is the box the
// compositor will draw into and not the number of device pixels it will
// fill. `display_ui::physical` converts, so that a fractionally scaled
// desktop is presented 1:1 rather than resampled — see that function for
// why a soft canvas is the failure being avoided here.
{
let weak = window.as_weak();
let viewport = viewport.clone();
let redraw = redraw.clone();
let display = display.clone();
window.on_canvas_resized(move |w_px, h_px| {
let Some(w) = weak.upgrade() else { return };
let logical = (w_px.max(1) as u32, h_px.max(1) as u32);
let size = display.canvas_resized(&w, logical);
if *viewport.borrow() == size {
return;
}
*viewport.borrow_mut() = size;
redraw(&w);
});
}
// TRACES: FR-DSP-8 | FR-DSP-6
// And which display that canvas is on, from now until the window closes.
display_ui::attach(&window, &display, &viewport, redraw.clone());
// 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());
// FR-UI-2: the ceiling on the develop column, computed here for the same
// reason the class is — a width that both derives from and feeds the
// layout is a binding loop in Slint.
window.set_panel_max_width((width * PANEL_MAX_FRACTION).max(PANEL_MIN_WIDTH));
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(),
mode: w.get_view_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::LeaveMode => w.invoke_mode_picked(ViewMode::Photo),
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.
// No `Eq`: `ViewMode` is generated by Slint and derives only `PartialEq`,
// which is all the comparisons below need. `Default` still derives, and it
// gives `mode` the enum's own first variant — `photo`, which is what "no mode"
// means and what the tests below want as their baseline.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct NavState {
settings: bool,
launch: bool,
browsing: bool,
library: bool,
/// Which develop mode is on, if any. One field rather than one flag per
/// mode, so "leave the innermost" cannot be asked of two at once — the
/// ordering below would have had to invent an answer for a state the
/// interface can no longer be in.
mode: ViewMode,
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,
/// Leave whichever develop mode is on — crop or local — and return to the
/// whole photograph. One step for both, because there is one mode at a
/// time and "back" means the same thing from either.
LeaveMode,
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 and local are modes and zoom is a view state; all are
// left before the image is.
//
// The mode goes before the zoom because that is the order they were
// entered in — a photographer zooms to place a mask, not the other way
// about — and because leaving local mode drops the mask selection,
// which is a bigger step back than returning to fit.
if s.mode != ViewMode::Photo {
return Some(BackStep::LeaveMode);
}
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,
mode: ViewMode::Crop,
..developing()
};
assert_eq!(back_step(from_develop), Some(BackStep::CloseSettings));
}
#[test]
fn back_leaves_a_mode_before_it_leaves_the_image() {
// A mode, then zoom, then the view: innermost first, because that is
// the order they were entered in.
for mode in [ViewMode::Crop, ViewMode::Local] {
let in_mode = NavState {
mode,
zoomed: true,
..developing()
};
assert_eq!(
back_step(in_mode),
Some(BackStep::LeaveMode),
"{mode:?} must be left before the zoom is reset"
);
}
let zoomed = NavState {
zoomed: true,
..developing()
};
assert_eq!(back_step(zoomed), Some(BackStep::ResetZoom));
assert_eq!(back_step(developing()), Some(BackStep::ToLibrary));
}
/// TRACES: FR-UI-5
/// Local masking joins the existing order rather than inventing an exit.
///
/// It is the point of making it a mode: before this, back and Escape did
/// nothing about a masking session, so the only way out of it was to find
/// the two toggles that had armed it and press them again — and neither
/// was anywhere near the photograph the user was looking at.
#[test]
fn local_masking_is_left_by_the_same_step_crop_is() {
let masking = NavState {
mode: ViewMode::Local,
..developing()
};
assert_eq!(back_step(masking), Some(BackStep::LeaveMode));
}
#[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
);
}
}