`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.
3179 lines
133 KiB
Rust
3179 lines
133 KiB
Rust
//! 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
|
||
);
|
||
}
|
||
}
|