Android's only export destination was the library on the server
(ExportTarget::available), because writing to the device goes through
the Storage Access Framework and nothing did. An album's folder on the
tablet is now chosen in the system's tree picker — which has its own
"Create new folder" — and exports are written into it with
DocumentsContract.
The picker answers through onActivityResult, and the main activity is
NativeActivity, whose result is not ours. FolderPicker is a translucent
activity that only asks: it starts ACTION_OPEN_DOCUMENT_TREE, takes a
persistable grant (a folder is chosen once and exported to for months),
leaves the URI in a static, and finishes. Rust polls it from a Slint
timer — one static call, rather than a registered native method and a
thread to deliver on.
Two things the first build on the tablet got wrong, recorded where they
are fixed:
- Our classes must be loaded through Context.getClassLoader(). The
class of what ndk_context holds is a framework class from the boot
loader, which reports every class in the APK as not found.
- What ndk_context holds is the application context, not the activity,
and starting an activity from it throws without FLAG_ACTIVITY_NEW_TASK.
Saf.write creates the document (or, under Overwrite, reopens the one of
that name with "wt" so a shorter file does not keep the old tail) and
returns the name the provider actually gave it, since SAF renames on a
collision by itself; the album records that name. A tree URI reads in
the sidebar as its folder ("Pictures/Web"), not as a content:// string.
3845 lines
166 KiB
Rust
3845 lines
166 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 albums_ui;
|
||
#[cfg(all(feature = "automation", unix))]
|
||
mod automation;
|
||
mod bursts;
|
||
mod collections_ui;
|
||
#[cfg(test)]
|
||
mod decoder_seam;
|
||
mod derived_sync;
|
||
mod develop;
|
||
mod develop_ui;
|
||
mod display_ui;
|
||
mod duplicates;
|
||
mod duplicates_ui;
|
||
mod export;
|
||
mod folder_dialog;
|
||
pub mod faces;
|
||
pub use library::render_native;
|
||
// Generated from the `GESTURE:` comments beside the code that implements each
|
||
// one — see `tools/traceability`. Regenerate with
|
||
// `cargo run -p traceability -- gestures`; CI fails if it has drifted.
|
||
mod gesture_book;
|
||
mod gestures;
|
||
mod gradient;
|
||
mod histogram;
|
||
pub mod identity;
|
||
mod identity_ui;
|
||
mod import;
|
||
mod import_ui;
|
||
pub mod inference;
|
||
mod labels;
|
||
mod library;
|
||
mod library_ui;
|
||
#[cfg(live_style)]
|
||
mod live_style;
|
||
pub mod manual;
|
||
mod masks_ui;
|
||
pub mod memory;
|
||
pub mod merge;
|
||
mod merge_ui;
|
||
mod net_runtime;
|
||
mod peaking;
|
||
mod place;
|
||
mod preset_store;
|
||
mod presets;
|
||
mod recovery_ui;
|
||
mod refine;
|
||
mod remote;
|
||
mod remote_folders;
|
||
#[cfg(target_os = "android")]
|
||
mod saf;
|
||
pub mod repairs;
|
||
mod segmentation;
|
||
mod settings_store;
|
||
mod settings_ui;
|
||
mod sidecar_cache;
|
||
mod spots_ui;
|
||
mod trash;
|
||
mod xmp_sync;
|
||
|
||
use std::cell::{Cell, RefCell};
|
||
use std::path::{Path, PathBuf};
|
||
use std::rc::Rc;
|
||
|
||
use anyhow::Result;
|
||
use dr_decode::{Decoder, Metadata, PreviewSize};
|
||
use slint::ComponentHandle as _;
|
||
|
||
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::inpaint_model;
|
||
pub use library::shared_face_models_dir;
|
||
pub use library::FaceModelPaths;
|
||
|
||
/// The scene model's three files, wherever this device keeps them.
|
||
///
|
||
/// Public for the same reason as the directory above: the pieces that reach for
|
||
/// a model are not all inside this crate. Exported ahead of the scene tab that
|
||
/// will consume it so that the packaging and unpacking added alongside it have
|
||
/// something to be verified against — `assemble-apk.sh` writing files no lookup
|
||
/// looks for would be a silent mistake for as long as the tab took to arrive.
|
||
pub use library::scene_model;
|
||
|
||
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 desktop window dragged under this gets
|
||
/// the compact layout exactly as a small screen would, and that is FR-UI-1's
|
||
/// rule rather than a convenience.
|
||
///
|
||
/// **The tablet in portrait is not that case**, and it is worth saying so here
|
||
/// because this comment used to claim it was. The tablet's panel is about 960
|
||
/// logical pixels across in portrait, which clears 820 — so portrait is
|
||
/// expanded, and turning the device does not change the class at all. What it
|
||
/// changes is which axis the develop column is on, which is `column_below` and
|
||
/// D-N7, and nothing to do with this number.
|
||
const EXPANDED_MIN_WIDTH: f32 = 820.0;
|
||
|
||
/// TRACES: FR-UI-1
|
||
/// The largest share of the window the develop column may take.
|
||
///
|
||
/// A policy, not a size. The column is a fixed `panel-width` (`style.yaml`),
|
||
/// so on any window with room for it this does nothing at all — it bites only
|
||
/// where 360px would be most of the screen, and what it says there is that the
|
||
/// majority of the window stays with the photograph the column exists to
|
||
/// serve.
|
||
///
|
||
/// It used to do more, because the column used to size itself to the widest
|
||
/// thing it held and could therefore grow without limit on a rich operation
|
||
/// set. It cannot any more; this is the remaining half of that guard, kept for
|
||
/// the case the fixed width does not cover.
|
||
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.
|
||
/// Under `panel-width` by design: between the two the column narrows from 360
|
||
/// to 280 on a small window and stops, which is the range the sliders inside
|
||
/// it stay accurate over.
|
||
const PANEL_MIN_WIDTH: f32 = 280.0;
|
||
|
||
/// TRACES: FR-UI-1
|
||
/// The window aspect — height over width — at which the develop column moves
|
||
/// from beside the photograph to under it (D-N7).
|
||
///
|
||
/// Above 1.0 rather than at it, because a window barely taller than it is wide
|
||
/// has nothing to gain. D-N7's arithmetic only comes out for the dock once the
|
||
/// photograph left beside a 360px column has become a strip, and 1.25 is where
|
||
/// that starts: the tablet in portrait is about 960 × 1500, which is 1.56.
|
||
const DOCK_ENTER_ASPECT: f32 = 1.25;
|
||
|
||
/// And the aspect at which it moves back beside the photograph.
|
||
///
|
||
/// Below `DOCK_ENTER_ASPECT` rather than equal to it, and that gap is the
|
||
/// whole point. This is read on every resize event, so a single threshold
|
||
/// means a window dragged along its own diagonal crosses it several times a
|
||
/// second and the column jumps from one axis to the other and back while the
|
||
/// pointer is still down. The dead band between 1.15 and 1.25 is wide enough
|
||
/// that no plausible drag re-crosses it and narrow enough that the flag still
|
||
/// answers to the window's shape rather than to its history.
|
||
const DOCK_LEAVE_ASPECT: f32 = 1.15;
|
||
|
||
/// 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>,
|
||
decoder: &dyn Decoder,
|
||
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, decoder, &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>,
|
||
decoder: &dyn Decoder,
|
||
bytes: &[u8],
|
||
) -> Result<Loaded, String> {
|
||
let meta = decoder.metadata(bytes).unwrap_or_default();
|
||
|
||
// How the file stored its pixels, for the preview path below — the develop
|
||
// path takes it from the same header inside `open_session`. 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, decoder, bytes, &meta) {
|
||
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 = decoder
|
||
.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-EXP-8 | 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.
|
||
///
|
||
/// Takes the decoded header rather than just the orientation out of it. Both
|
||
/// callers had already read it, and the session keeps it: this is the one
|
||
/// place a photograph becomes editable, so making the header an argument here
|
||
/// is what makes "a session knows which file it came from" true by
|
||
/// construction rather than by everybody remembering to say so.
|
||
pub(crate) fn open_session(
|
||
ctx: &dr_gpu::GpuContext,
|
||
decoder: &dyn Decoder,
|
||
bytes: &[u8],
|
||
meta: &Metadata,
|
||
) -> Result<DevelopSession, String> {
|
||
// How the file stored its pixels. A file that says nothing is taken as
|
||
// upright — see `Orientation::from_exif`.
|
||
let mut session = open_pixels(ctx, decoder, bytes, meta.orientation.unwrap_or_default())?;
|
||
|
||
// TRACES: FR-EXP-8
|
||
// The header goes with the session rather than being read again later,
|
||
// and this function takes it rather than an orientation so that there is
|
||
// no way to open a photograph for editing without saying what file it came
|
||
// from. An export from the develop button carries the camera, the lens and
|
||
// the capture date because of this line; before it, the same photograph
|
||
// exported from the grid kept them and exported from develop did not.
|
||
session.set_source_metadata(meta.clone());
|
||
Ok(session)
|
||
}
|
||
|
||
/// Decode bytes into a session, with no header attached.
|
||
///
|
||
/// Split from [`open_session`] only so the routing below stays one expression
|
||
/// with two early returns; every caller wants the version that remembers where
|
||
/// the pixels came from.
|
||
fn open_pixels(
|
||
ctx: &dr_gpu::GpuContext,
|
||
decoder: &dyn Decoder,
|
||
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).
|
||
decoder
|
||
.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) {
|
||
// Whatever the last open was waiting for, this one is not — until the
|
||
// remote path says otherwise.
|
||
window.set_load_pending(false);
|
||
window.set_load_waiting("".into());
|
||
window.set_load_fraction(-1.0);
|
||
window.set_has_load_preview(false);
|
||
window.global::<Develop>().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);
|
||
let framing = window.global::<Framing>();
|
||
framing.set_max_straighten(dr_pipeline::framing::MAX_STRAIGHTEN);
|
||
framing.set_angle(0.0);
|
||
framing.set_max_keystone(dr_pipeline::framing::MAX_KEYSTONE);
|
||
framing.set_keystone_v(0.0);
|
||
framing.set_keystone_h(0.0);
|
||
framing.set_flip_h(false);
|
||
framing.set_flip_v(false);
|
||
framing.set_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.
|
||
let steps = window.global::<Steps>();
|
||
steps.set_can_undo(false);
|
||
steps.set_can_redo(false);
|
||
// TRACES: FR-DEV-7
|
||
// And the comparison goes down with them. Unlike the inspection point
|
||
// below it, this is not a way of looking at a folder: it is a question
|
||
// about one photograph's edit, asked while holding something. A key
|
||
// release that arrived after the next frame had opened would put the view
|
||
// back anyway, but a press that opens the next photograph — the roll is a
|
||
// tap away — must not carry a held original onto it.
|
||
window.set_showing_original(false);
|
||
// TRACES: FR-DEV-3
|
||
// And an armed sampler goes down with it: a picker left waiting would make
|
||
// the first click on the *next* photograph sample it, which is a click
|
||
// nobody meant to spend.
|
||
window.global::<Adjustments>().set_sampling_op(-1);
|
||
// TRACES: FR-DEV-16
|
||
// And the control last moved, which R resets: the indices are into this
|
||
// photograph's rows, and a reset that reached back into the previous edit
|
||
// would land on whichever control happens to share them.
|
||
window.global::<Adjustments>().set_touched_op(-1);
|
||
window.global::<Adjustments>().set_touched_param(-1);
|
||
window.global::<Framing>().set_touched(0);
|
||
// 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.global::<Levels>().set_data(histogram::empty());
|
||
// And the raw reading with it. It belongs to one file more completely than
|
||
// the display histogram does — nothing about the edit can move it — which
|
||
// makes leaving it standing over the next photograph's filename worse
|
||
// rather than better: it would look exactly as current as it is wrong.
|
||
window
|
||
.global::<Levels>()
|
||
.set_raw_data(histogram::raw_empty(histogram::RawAbsence::NoImage));
|
||
// TRACES: FR-CULL-3
|
||
// The marks go down with it, and for the same reason. What is *not* reset
|
||
// is whether peaking is switched on: that is a way of looking at a folder
|
||
// rather than a property of one photograph, so it survives to the next
|
||
// frame — see `chosen_peaking` for the whole of that argument.
|
||
window.set_focus_overlay_ready(false);
|
||
// 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);
|
||
}
|
||
|
||
/// TRACES: FR-UI-4
|
||
/// Where the 1:1 view is pointing, and whether it is currently on.
|
||
///
|
||
/// **Two facts rather than an `Option`, and the second one is why.** Returning
|
||
/// to fit turns the magnifier off; it does not mean the photographer has
|
||
/// stopped caring about the corner they were inspecting. An `Option` emptied
|
||
/// on the way out would send the next press of the same control back to the
|
||
/// centre of the frame, which is the one place nobody was looking.
|
||
///
|
||
/// The point is in fractions of the *framed* image, because that is the only
|
||
/// coordinate system two different photographs share.
|
||
#[derive(Debug, Clone, Copy)]
|
||
struct Inspection {
|
||
at: (f32, f32),
|
||
on: bool,
|
||
}
|
||
|
||
impl Default for Inspection {
|
||
fn default() -> Self {
|
||
// The centre, which is where a magnifier that has never been aimed
|
||
// points. `(0.0, 0.0)` would be the top-left corner — a real place,
|
||
// and never the one meant.
|
||
Self {
|
||
at: (0.5, 0.5),
|
||
on: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-UI-4
|
||
/// Put a newly opened photograph under the magnifier the last one was under.
|
||
///
|
||
/// The other half of `reset_view_state`, and deliberately not part of it: that
|
||
/// function empties what belonged to one photograph, and the inspection point
|
||
/// belongs to the *pass*. Checking the same eye on forty portraits is why a
|
||
/// 1:1 view is worth reaching at all, and a magnification that reset with the
|
||
/// session would make it forty zooms and forty pans instead of forty
|
||
/// keystrokes.
|
||
///
|
||
/// Called after the stored edit has landed rather than before it, because the
|
||
/// point is in fractions of the *framed* image: a sidecar carrying a crop
|
||
/// changes what a fraction refers to, and inspecting before it arrived would
|
||
/// land somewhere the photograph does not have any more.
|
||
///
|
||
/// Silently does nothing where the file could not be opened for editing —
|
||
/// there is no view to place, and the next photograph that does open is still
|
||
/// inspected.
|
||
fn resume_inspection(
|
||
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||
viewport: &Rc<RefCell<(u32, u32)>>,
|
||
inspection: &Rc<Cell<Inspection>>,
|
||
) {
|
||
let held = inspection.get();
|
||
if !held.on {
|
||
return;
|
||
}
|
||
let (vw, vh) = *viewport.borrow();
|
||
if let Some(s) = session.borrow_mut().as_mut() {
|
||
s.inspect_at(held.at.0, held.at.1, vw, vh);
|
||
}
|
||
}
|
||
|
||
/// 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(), s.keystone(), h, v, s.framing_edits_image(), c)
|
||
}) else {
|
||
return;
|
||
};
|
||
let (angle, (keystone_v, keystone_h), flip_h, flip_v, modified, crop) = s;
|
||
let framing = window.global::<Framing>();
|
||
framing.set_angle(angle);
|
||
framing.set_keystone_v(keystone_v);
|
||
framing.set_keystone_h(keystone_h);
|
||
framing.set_flip_h(flip_h);
|
||
framing.set_flip_v(flip_v);
|
||
framing.set_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-DEV-3
|
||
/// Push the chosen crop ratio back to the panel.
|
||
///
|
||
/// The index is written from the choice actually held rather than from what
|
||
/// was clicked, for the same reason [`sync_framing`] writes the applied crop:
|
||
/// picking a ratio with no portrait form clears the orientation switch, and a
|
||
/// panel showing the click rather than the result would light a switch that is
|
||
/// doing nothing.
|
||
fn sync_crop_aspect(
|
||
window: &AppWindow,
|
||
aspect: &Rc<Cell<develop::CropAspect>>,
|
||
portrait: &Rc<Cell<bool>>,
|
||
) {
|
||
let chosen = aspect.get();
|
||
let index = develop::CropAspect::CHOICES
|
||
.iter()
|
||
.position(|a| *a == chosen)
|
||
.unwrap_or(0);
|
||
let framing = window.global::<Framing>();
|
||
framing.set_aspect(index as i32);
|
||
framing.set_portrait(portrait.get());
|
||
framing.set_aspect_turnable(chosen.has_orientation());
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// Reshape the open image's crop onto the chosen ratio, about its centre.
|
||
///
|
||
/// Called when the ratio itself changes, never on opening a photograph: the
|
||
/// lock outlives the session it is applied to, and reshaping a crop the user
|
||
/// has not touched — on an image they have only just opened — would edit their
|
||
/// work as a side effect of stepping through a shoot. A ratio takes effect
|
||
/// when it is chosen and when a handle is dragged, both of which are things
|
||
/// the user did on purpose.
|
||
fn apply_crop_aspect(
|
||
window: &AppWindow,
|
||
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||
aspect: &Rc<Cell<develop::CropAspect>>,
|
||
portrait: &Rc<Cell<bool>>,
|
||
) {
|
||
let Some(s) = session.borrow_mut().as_mut().map(|s| {
|
||
let before = s.framing();
|
||
s.set_crop_locked(s.crop(), aspect.get(), portrait.get(), (0.5, 0.5));
|
||
// TRACES: FR-DEV-17
|
||
// A ratio chosen is a crop committed in one click — there is no drag
|
||
// to wait for the end of — so it is measured here, as a release is.
|
||
s.notice_hidden_masks(&before);
|
||
(s.crop(), s.framing_edits_image())
|
||
}) else {
|
||
return;
|
||
};
|
||
let (crop, modified) = s;
|
||
window.set_crop_x(crop.x);
|
||
window.set_crop_y(crop.y);
|
||
window.set_crop_w(crop.width);
|
||
window.set_crop_h(crop.height);
|
||
window.global::<Framing>().set_modified(modified);
|
||
}
|
||
|
||
/// 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());
|
||
|
||
// TRACES: FR-EXP-8
|
||
// The header the session was opened from, cloned because the frame is
|
||
// about to leave this thread. `None` where the file had none to read,
|
||
// which the worker takes as "say nothing" rather than as a reason to
|
||
// invent something.
|
||
let header = session.source_metadata().cloned();
|
||
|
||
Ok(export::Source::Rendered {
|
||
stem,
|
||
header,
|
||
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,
|
||
images: Vec::new(),
|
||
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(),
|
||
decoder: dr_decode::default(),
|
||
}
|
||
}
|
||
|
||
/// 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.
|
||
pub(crate) fn refresh_export_label(window: &AppWindow) {
|
||
use slint::Model as _;
|
||
// The album the choice in `ExportOptions` points at, as `albums_ui` last
|
||
// drew it — the one place that knows both the settings and the catalog.
|
||
let options = window.global::<ExportOptions>();
|
||
let album = usize::try_from(options.get_album_selected())
|
||
.ok()
|
||
.and_then(|i| options.get_album_labels().row_data(i))
|
||
.map(|s| s.to_string())
|
||
.unwrap_or_default();
|
||
window.set_export_label(
|
||
if album.is_empty() {
|
||
"Export".to_string()
|
||
} else {
|
||
format!("Export to {album}")
|
||
}
|
||
.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 name rather than the
|
||
// sentence (FR-EXP-7).
|
||
window.set_export_album(album.into());
|
||
}
|
||
|
||
/// Push current parameter values back to the interface.
|
||
///
|
||
/// The controls are not self-updating: the core clamps values, so what the
|
||
/// user dragged to and what the parameter became can differ, and the control
|
||
/// must show the latter.
|
||
/// **Updates rows in place; never replaces the model.** Assigning a fresh
|
||
/// `ModelRc` tears down and rebuilds every row element — including the
|
||
/// `TouchArea` currently tracking the pointer — which cancels the drag in
|
||
/// progress. The symptom is a slider that jumps on click but cannot be
|
||
/// dragged, because each move event destroys the thing that would deliver
|
||
/// the next one.
|
||
/// TRACES: FR-CAT-8
|
||
/// Apply a fetched sidecar to the open session, now or as soon as it arrives.
|
||
///
|
||
/// The sidecar fetch is started beside the image fetch and is three orders of
|
||
/// magnitude smaller, so it has almost always landed by the time there is a
|
||
/// session to apply it to — and this takes it straight from the channel. The
|
||
/// timer covers the case where it has not, which is why this is not simply a
|
||
/// blocking receive: a slow or stalled sidecar request must not freeze the
|
||
/// window with the photograph already decoded and on screen.
|
||
///
|
||
/// A late arrival redraws, so the image is correct either way; the only
|
||
/// difference is whether it was ever briefly shown at its defaults.
|
||
fn apply_when_ready(
|
||
window: &AppWindow,
|
||
rx: Rc<std::sync::mpsc::Receiver<Option<dr_pipeline::Sidecar>>>,
|
||
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||
rows: &Rc<slint::VecModel<ParamRow>>,
|
||
redraw: &Rc<dyn Fn(&AppWindow)>,
|
||
) {
|
||
// Already here — the overwhelmingly common case.
|
||
if let Ok(got) = rx.try_recv() {
|
||
if let Some(sidecar) = got {
|
||
presets::apply_stored_edit(window, &sidecar, session, rows);
|
||
}
|
||
return;
|
||
}
|
||
|
||
let weak = window.as_weak();
|
||
let session = session.clone();
|
||
let rows = rows.clone();
|
||
let redraw = redraw.clone();
|
||
let timer = Rc::new(slint::Timer::default());
|
||
let held = timer.clone();
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
std::time::Duration::from_millis(50),
|
||
move || {
|
||
let Ok(got) = rx.try_recv() else { return };
|
||
held.stop();
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let Some(sidecar) = got else { return };
|
||
if presets::apply_stored_edit(&w, &sidecar, &session, &rows) {
|
||
redraw(&w);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 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);
|
||
|
||
// Whether the picker belongs in the group on screen. Asked of the session
|
||
// rather than decided here: it is a fact about what the operation is
|
||
// about, and the answer moves if the descriptor ever does.
|
||
let in_group = session.borrow().as_ref().is_none_or(|s| s.film_in_group());
|
||
|
||
let can_print = choices
|
||
.get(selected)
|
||
.and_then(|(id, _)| *id)
|
||
.and_then(dr_film::find)
|
||
.and_then(dr_film::default_print)
|
||
.is_some();
|
||
|
||
let adjustments = window.global::<Adjustments>();
|
||
adjustments.set_film_stocks(slint::ModelRc::new(slint::VecModel::from(
|
||
choices
|
||
.into_iter()
|
||
.map(|(_, name)| slint::SharedString::from(name))
|
||
.collect::<Vec<_>>(),
|
||
)));
|
||
adjustments.set_film_selected(selected as i32);
|
||
adjustments.set_film_in_group(in_group);
|
||
adjustments.set_film_can_print(can_print);
|
||
adjustments.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
|
||
.global::<Adjustments>()
|
||
.set_tabs(slint::ModelRc::new(slint::VecModel::from(tabs)));
|
||
window.global::<Adjustments>().set_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
|
||
.global::<Adjustments>()
|
||
.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.global::<Adjustments>().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
|
||
.global::<Adjustments>()
|
||
.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.
|
||
///
|
||
/// **The same on Android**, where Slint draws with Skia rather than FemtoVG
|
||
/// but on this device all the same. It used to open a device of its own
|
||
/// there and read every frame back (TD-1), because wgpu's Vulkan swapchain
|
||
/// could not pre-rotate and a portrait window tore on the tablet's
|
||
/// landscape-mounted panel. The patched wgpu-hal and Skia renderer in
|
||
/// `third_party/` pre-rotate it now; see technical-debt.md TD-1.
|
||
fn shared_gpu() -> Option<dr_gpu::GpuContext> {
|
||
let shared = match pollster::block_on(dr_gpu::GpuContext::new_shared()) {
|
||
Ok(shared) => shared,
|
||
Err(e) => {
|
||
log::warn!("no shareable GPU: {e}");
|
||
return None;
|
||
}
|
||
};
|
||
let dr_gpu::SharedGpu {
|
||
ctx,
|
||
instance,
|
||
adapter,
|
||
} = shared;
|
||
|
||
// `Manual` is the variant that means "render with these, do not open your
|
||
// own". The two clones are of wgpu handles, which are refcounts over the
|
||
// one device and the one queue — not copies of either.
|
||
let configuration = slint::wgpu_29::WGPUConfiguration::Manual {
|
||
instance,
|
||
adapter,
|
||
device: (*ctx.device).clone(),
|
||
queue: (*ctx.queue).clone(),
|
||
};
|
||
|
||
if let Err(e) = slint::BackendSelector::new()
|
||
.require_wgpu_29(configuration)
|
||
.select()
|
||
{
|
||
// Dropping the context rather than keeping it: Slint has fallen back
|
||
// to a renderer that did not adopt our device, so every texture this
|
||
// context produces is one the compositor cannot sample. A disabled
|
||
// develop panel is a visible, explicable failure; a texture handed
|
||
// across devices is undefined behaviour on a good day.
|
||
log::warn!("Slint would not adopt the GPU device, develop disabled: {e}");
|
||
return None;
|
||
}
|
||
|
||
Some(ctx)
|
||
}
|
||
|
||
/// TRACES: M-13 | M-14
|
||
/// Build and run the viewer.
|
||
pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||
// Mutable because the browsing list has two sources: the command line at
|
||
// startup, and whatever the library grid is showing when a cell is
|
||
// clicked. Opening from the grid replaces this so next/previous walk the
|
||
// library the user is actually looking at rather than the arguments they
|
||
// launched with.
|
||
let entries = Rc::new(RefCell::new(collect(&paths)));
|
||
log::info!("{} image(s) to browse", entries.borrow().len());
|
||
|
||
// Everything between here and `window.run()` at the bottom happens before
|
||
// the event loop exists, and on Android that is not merely a slow start:
|
||
// `android_main` calls this, and nothing drains the activity's input
|
||
// channel until Slint reaches its first `poll_events` inside `run()`. Five
|
||
// seconds of it is an ANR, over a window that has never painted.
|
||
//
|
||
// So the two phases that touch a driver or a disk are timed, and so is the
|
||
// whole of it. There is no profiler on the tablet and no way to attach one
|
||
// to a launch: a line in the log file is the only measurement anybody gets,
|
||
// and without it the next regression here is guesswork about which of half
|
||
// a dozen candidates it was.
|
||
let launch_began = std::time::Instant::now();
|
||
|
||
// 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.
|
||
//
|
||
// Timed rather than moved: this is a Vulkan instance, an adapter
|
||
// enumeration and a device request, none of which can be deferred
|
||
// because the backend must be chosen before a window exists.
|
||
let gpu = shared_gpu();
|
||
log::info!("gpu opened in {} ms", launch_began.elapsed().as_millis());
|
||
|
||
let window = init_window(&gpu)?;
|
||
wire_inference_status(&window);
|
||
wire_diagnostics(&window, &gpu);
|
||
|
||
// 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);
|
||
|
||
let (library, prefetch, collections, identity, settings) = construct_screens(
|
||
&window,
|
||
&paths,
|
||
&activity,
|
||
&gpu,
|
||
&open_from_library,
|
||
&leave_develop,
|
||
);
|
||
// Held for the life of the window only to keep the People screen's cover
|
||
// cache registered with the thumbnail tier above — nothing here reads it
|
||
// again before `window.run()`.
|
||
let _identity = identity;
|
||
|
||
wire_import_and_merge(&window, &activity, &gpu, &library, &collections, &settings);
|
||
wire_settings_screen(&window, &settings, &library);
|
||
|
||
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-PLAT-AND-5
|
||
// The GPU tier — the first thing given back under memory pressure, and on
|
||
// Android the only thing given back merely for going into the background.
|
||
//
|
||
// `try_borrow_mut` rather than `borrow_mut`, and the miss is not an error
|
||
// worth reporting. A memory warning can land in the middle of a render, at
|
||
// which point the slot is already borrowed and freeing its textures under
|
||
// the code drawing with them is not something to do politely — skipping is
|
||
// correct, because the pass that is running will have finished by the time
|
||
// the platform asks again, and a warning that has not been acted on is
|
||
// always followed by another one.
|
||
{
|
||
let session = Rc::downgrade(&session);
|
||
memory::evict_at(memory::Tier::Gpu, move || {
|
||
let Some(session) = session.upgrade() else {
|
||
return;
|
||
};
|
||
let Ok(mut slot) = session.try_borrow_mut() else {
|
||
return;
|
||
};
|
||
if let Some(open) = slot.as_mut() {
|
||
open.release_gpu_caches();
|
||
}
|
||
});
|
||
}
|
||
|
||
// 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.global::<Adjustments>().set_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();
|
||
|
||
// TRACES: FR-CULL-3
|
||
// How the photographer wants focus peaking drawn, or `None` for off.
|
||
//
|
||
// **Held here rather than on the session, which is the opposite of where
|
||
// every edit lives.** A session is one photograph; peaking is a way of
|
||
// *looking* at a folder of them. Someone culling three thousand frames
|
||
// switches it on once, and a flag that reset with the session would ask
|
||
// them to switch it on three thousand times — which is why
|
||
// `reset_view_state` deliberately leaves it alone while emptying the
|
||
// histogram beside it.
|
||
let chosen_peaking: Rc<Cell<Option<dr_gpu::FocusPeaking>>> = Rc::new(Cell::new(None));
|
||
|
||
// TRACES: FR-UI-4
|
||
// The point being inspected at 1:1, in fractions of the framed image, or
|
||
// `None` where the view is fitted.
|
||
//
|
||
// **Held here rather than on the session, on exactly the argument
|
||
// `chosen_peaking` above makes.** A session is one photograph and this is
|
||
// a way of looking at a folder of them: checking the same eye across forty
|
||
// portraits is the entire reason a 1:1 view is worth reaching, and a
|
||
// magnification that reset with the session would ask for the same zoom
|
||
// and the same pan forty times. So `reset_view_state` empties the
|
||
// histogram and leaves this alone, and the two open paths below put the
|
||
// next photograph under the magnifier the last one was left under.
|
||
//
|
||
// It is a *viewing* state throughout. It never reaches the graph's crop,
|
||
// the sidecar or an export — `Framing::view` is kept out of all three, and
|
||
// `render_the_file` suspends it — so a photographer left at 4× exports the
|
||
// whole frame, as they did before this existed.
|
||
let inspection: Rc<Cell<Inspection>> = Rc::new(Cell::new(Inspection::default()));
|
||
|
||
let render_now = build_render_now(&session, &viewport, &display, &chosen_peaking);
|
||
let redraw = build_redraw(&render_now);
|
||
let show = build_show(
|
||
&entries,
|
||
&index,
|
||
&session,
|
||
&redraw,
|
||
&gpu,
|
||
&rows,
|
||
&open_image,
|
||
&library,
|
||
&viewport,
|
||
&inspection,
|
||
);
|
||
|
||
// Now `show` exists, close the knot left open at the library wiring.
|
||
wire_remote_open(
|
||
&window,
|
||
&open_from_library,
|
||
&library,
|
||
&session,
|
||
&redraw,
|
||
&rows,
|
||
&gpu,
|
||
&activity,
|
||
&open_image,
|
||
&viewport,
|
||
&inspection,
|
||
&prefetch,
|
||
);
|
||
|
||
develop_ui::wire(
|
||
&window,
|
||
&session,
|
||
&rows,
|
||
&redraw,
|
||
&render_now,
|
||
&show,
|
||
&entries,
|
||
&index,
|
||
&viewport,
|
||
&inspection,
|
||
&chosen_peaking,
|
||
&settings,
|
||
&library,
|
||
&collections,
|
||
&activity,
|
||
&gpu,
|
||
&clipboard,
|
||
&open_image,
|
||
&leave_develop,
|
||
);
|
||
|
||
wire_shell(&window, &viewport, &redraw, &display);
|
||
|
||
if !entries.borrow().is_empty() {
|
||
show(&window);
|
||
}
|
||
|
||
// The figure that matters, because it is the one the input dispatcher is
|
||
// counting against. Anything approaching five seconds here is an ANR on the
|
||
// next Android launch whatever the phases above say, and anything that
|
||
// pushes it up belongs on a worker.
|
||
log::info!(
|
||
"startup: {} ms to the event loop",
|
||
launch_began.elapsed().as_millis()
|
||
);
|
||
|
||
#[cfg(all(feature = "automation", unix))]
|
||
automation::attach(&window);
|
||
|
||
window.run()?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Build the window and set what has to be true before it is shown: the
|
||
/// window-manager app id, the About page's version, and the backend line
|
||
/// that names the adapter (or says there is none).
|
||
fn init_window(gpu: &Option<dr_gpu::GpuContext>) -> Result<AppWindow> {
|
||
let gpu = gpu.clone();
|
||
|
||
let before_window = std::time::Instant::now();
|
||
let window = AppWindow::new()?;
|
||
log::info!("window built in {} ms", before_window.elapsed().as_millis());
|
||
|
||
// Which screens browse for a path and which take one typed: see
|
||
// `folder_dialog` for why Android is the exception.
|
||
window
|
||
.global::<Pickers>()
|
||
.set_local_paths(folder_dialog::AVAILABLE);
|
||
|
||
// 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.
|
||
//
|
||
// **After the window is constructed, and before it is shown.** This used
|
||
// to sit at the top of `run`, on the reasoning that the app id is read
|
||
// when the surface is created — which is true, and still left it never
|
||
// set. `slint::set_xdg_app_id` goes through the global context, and there
|
||
// is no global context until something installs a Slint platform: either
|
||
// `BackendSelector` in `shared_gpu`, or `AppWindow::new` falling back to
|
||
// the default. Called before both, it returned `NoPlatform` and did
|
||
// nothing, and GNOME showed a generic tile for the window from the day
|
||
// the call was written. Constructing a window is not showing it — `run`
|
||
// is far below — so this is both late enough to have a platform and early
|
||
// enough to be read.
|
||
if let Err(e) = slint::set_xdg_app_id("paris.tourolle.darkroom") {
|
||
// Not fatal, but not silent either: the cost is the launcher and the
|
||
// task bar showing a grey square for a window that is working
|
||
// perfectly, which is invisible from inside the application and was
|
||
// missed for exactly that reason. X11 and Android reach here too,
|
||
// where the call is a no-op and the message is merely noise at debug.
|
||
log::warn!("could not set the xdg app id, so the launcher icon will be generic: {e}");
|
||
}
|
||
// 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()),
|
||
}
|
||
Ok(window)
|
||
}
|
||
|
||
/// TRACES: FR-INF-1
|
||
/// What the models run on. Re-read every two seconds because the answer
|
||
/// changes twice after launch — when the probe reports and as each
|
||
/// engine lands — and the page is open for longer than either takes.
|
||
fn wire_inference_status(window: &AppWindow) {
|
||
let set = |w: &AppWindow| {
|
||
let (line, detail) = inference::about_lines();
|
||
w.set_inference_backend(line.into());
|
||
w.set_inference_detail(detail.into());
|
||
};
|
||
set(window);
|
||
let weak = window.as_weak();
|
||
let timer = Rc::new(slint::Timer::default());
|
||
let held = timer.clone();
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
std::time::Duration::from_secs(2),
|
||
move || {
|
||
let _keep = &held;
|
||
if let Some(w) = weak.upgrade() {
|
||
set(&w);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
/// TRACES: NFR-OPS-1
|
||
/// The diagnostics bundle, wired as the two presses the requirement
|
||
/// describes. Preparing gathers the log and the crash records into memory
|
||
/// and shows what would be written; saving writes it; discarding drops it
|
||
/// and writes nothing. The gathered bundle is held between the presses so
|
||
/// that what is saved is exactly what was shown — a bundle gathered again
|
||
/// at save time could differ from its own preview by whatever was logged
|
||
/// while the user was reading.
|
||
fn wire_diagnostics(window: &AppWindow, gpu: &Option<dr_gpu::GpuContext>) {
|
||
let gpu = gpu.clone();
|
||
|
||
use dr_plat::diagnostics::bundle::{Bundle, Facts};
|
||
let facts = Facts {
|
||
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||
schema_version: dr_catalog::schema::SCHEMA_VERSION,
|
||
graphics: match &gpu {
|
||
Some(ctx) => format!(
|
||
"{} ({:?}), {}",
|
||
ctx.adapter_name(),
|
||
ctx.backend(),
|
||
ctx.driver()
|
||
),
|
||
None => "no GPU".to_string(),
|
||
},
|
||
};
|
||
let pending: Rc<RefCell<Option<Bundle>>> = Rc::new(RefCell::new(None));
|
||
{
|
||
let weak = window.as_weak();
|
||
let pending = pending.clone();
|
||
window.on_diagnostics_prepare(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let bundle = Bundle::gather(&facts);
|
||
w.set_diagnostics_preview(
|
||
bundle
|
||
.preview(&dr_plat::diagnostics::bundle::default_dir())
|
||
.into(),
|
||
);
|
||
w.set_diagnostics_result("".into());
|
||
*pending.borrow_mut() = Some(bundle);
|
||
});
|
||
}
|
||
{
|
||
let weak = window.as_weak();
|
||
let pending = pending.clone();
|
||
window.on_diagnostics_save(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let Some(bundle) = pending.borrow_mut().take() else {
|
||
return;
|
||
};
|
||
let dir = dr_plat::diagnostics::bundle::default_dir();
|
||
let result = match bundle.write_to(&dir) {
|
||
Ok(path) => {
|
||
log::info!("diagnostics bundle written: {}", path.display());
|
||
format!("Saved as {}", path.display())
|
||
}
|
||
Err(e) => {
|
||
log::warn!("diagnostics bundle not written: {e}");
|
||
format!("Could not save the bundle to {}: {e}", dir.display())
|
||
}
|
||
};
|
||
w.set_diagnostics_preview("".into());
|
||
w.set_diagnostics_result(result.into());
|
||
});
|
||
}
|
||
{
|
||
let weak = window.as_weak();
|
||
window.on_diagnostics_discard(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
*pending.borrow_mut() = None;
|
||
w.set_diagnostics_preview("".into());
|
||
w.set_diagnostics_result("".into());
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Construct the library grid, the collections sidebar, the People screen
|
||
/// and the settings controller, and show whichever of launch, the local
|
||
/// files on the command line, or a remembered library the startup state
|
||
/// calls for.
|
||
///
|
||
/// Returns the controllers later sections need — `settings`, `library` and
|
||
/// `collections` for import, merge, export and presets — plus `identity` and
|
||
/// `prefetch`, kept alive by the caller for the life of the window.
|
||
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||
fn construct_screens(
|
||
window: &AppWindow,
|
||
paths: &[PathBuf],
|
||
activity: &Rc<activity::ActivityLog>,
|
||
gpu: &Option<dr_gpu::GpuContext>,
|
||
open_from_library: &Rc<RefCell<Option<Rc<dyn Fn(String)>>>>,
|
||
leave_develop: &Rc<RefCell<Option<Rc<dyn Fn()>>>>,
|
||
) -> (
|
||
Rc<library_ui::LibraryController>,
|
||
Rc<library::Prefetcher>,
|
||
Rc<collections_ui::CollectionsController>,
|
||
Rc<identity_ui::IdentityController>,
|
||
Rc<settings_ui::SettingsController>,
|
||
) {
|
||
let activity = activity.clone();
|
||
let gpu = gpu.clone();
|
||
let open_from_library = open_from_library.clone();
|
||
let leave_develop = leave_develop.clone();
|
||
|
||
// 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());
|
||
// TRACES: FR-NC-6a | FR-UI-4
|
||
// The photographs either side of the open one, fetched into the cache
|
||
// while it is being looked at, so a step along the roll is a disk read.
|
||
// Fed from the develop open below; its transfers are shown here.
|
||
let prefetch = Rc::new(library::Prefetcher::new());
|
||
show_prefetches(&prefetch, &activity);
|
||
// 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());
|
||
|
||
// Settings: cache ceilings, export defaults and the face grouping dials, in
|
||
// their own config file.
|
||
//
|
||
// Wired independently of every view below. 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 this far up because three separate things need the same record:
|
||
// the export action, the importer, and the People screen's grouping dials.
|
||
// A controller scoped to any one wiring block would be gone by the time the
|
||
// others were built, and two controllers each holding their own copy would
|
||
// each save over the other.
|
||
let settings = settings_ui::SettingsController::new();
|
||
|
||
// TRACES: FR-EXP-10
|
||
// Albums: the export folders under the collections. Held by the
|
||
// collections controller, which refreshes the sidebar they share, and
|
||
// reached through it by the export button.
|
||
let albums = albums_ui::AlbumsController::new(library.clone(), settings.clone());
|
||
*collections.albums.borrow_mut() = Some(albums.clone());
|
||
albums_ui::wire(window, albums.clone(), collections.clone());
|
||
|
||
// TRACES: FR-PLAT-AND-5
|
||
// The thumbnail tier. Registered here, beside the thing it frees, so that
|
||
// a controller which grows another cache is one line from offering it up.
|
||
//
|
||
// Weak, not strong: `run` returns when the window closes, and a registry
|
||
// holding the last reference to a controller would keep it — and every
|
||
// decoded portrait in it — alive past the interface it belonged to.
|
||
{
|
||
let identity = std::rc::Rc::downgrade(&identity);
|
||
memory::evict_at(memory::Tier::Thumbnails, move || {
|
||
if let Some(ctl) = identity.upgrade() {
|
||
ctl.clear_covers();
|
||
}
|
||
});
|
||
}
|
||
|
||
// 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());
|
||
// `OpenLibrary` is overwritten moments later by `library_ui::open`
|
||
// below, once a stored session exists; `ShowLocalFiles` leaves
|
||
// `develop` as it found it, since there is no library to switch to.
|
||
window.set_active_view(match startup {
|
||
launch::Startup::ShowLaunchScreen => View::Launch,
|
||
launch::Startup::ShowLocalFiles | launch::Startup::OpenLibrary => View::Develop,
|
||
});
|
||
|
||
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());
|
||
// An album is a third kind of scope, beside the two
|
||
// above, and the sidebar still has one selection:
|
||
// anything but an album there leaves the album.
|
||
if let Some(albums) = coll.albums.borrow().as_ref() {
|
||
if w.get_collection_selected() == albums_ui::ALBUM_SCOPE {
|
||
lib.set_album(albums.selected());
|
||
} else {
|
||
albums.deselect(&w);
|
||
}
|
||
}
|
||
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(),
|
||
settings.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/dev/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();
|
||
let settings = settings.clone();
|
||
move || {
|
||
let conn = lib.session()?;
|
||
library::face_models(&conn.account, settings.snapshot().faces.detector)
|
||
}
|
||
},
|
||
// 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))
|
||
}
|
||
},
|
||
// TRACES: FR-CULL-8
|
||
// The same device the develop view draws with. Face indexing
|
||
// renders natively through FR-EXP-9's path, so it needs a GPU
|
||
// for the same reason export does.
|
||
gpu.clone(),
|
||
);
|
||
}
|
||
|
||
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,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
(library, prefetch, collections, identity, settings)
|
||
}
|
||
|
||
/// 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.
|
||
fn wire_import_and_merge(
|
||
window: &AppWindow,
|
||
activity: &Rc<activity::ActivityLog>,
|
||
gpu: &Option<dr_gpu::GpuContext>,
|
||
library: &Rc<library_ui::LibraryController>,
|
||
collections: &Rc<collections_ui::CollectionsController>,
|
||
settings: &Rc<settings_ui::SettingsController>,
|
||
) {
|
||
let activity = activity.clone();
|
||
let gpu = gpu.clone();
|
||
let library = library.clone();
|
||
let collections = collections.clone();
|
||
let settings = settings.clone();
|
||
|
||
// 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());
|
||
|
||
// TRACES: FR-CAT-11a
|
||
// The duplicate originals review. What a consolidation changes is the
|
||
// grid (copies leave it), the sidebar's collections and trash, and the
|
||
// count the sidebar and settings show — so that is what it refreshes.
|
||
{
|
||
let duplicates = duplicates_ui::DuplicatesController::new(activity.clone());
|
||
let lib = library.clone();
|
||
let coll = collections.clone();
|
||
duplicates_ui::wire(
|
||
window,
|
||
duplicates,
|
||
library.clone(),
|
||
Rc::new(move |w: &AppWindow| {
|
||
library_ui::reload(w, &lib);
|
||
let catalog = lib.catalog();
|
||
let borrow = catalog.borrow();
|
||
if let Some(cat) = borrow.as_ref() {
|
||
collections_ui::refresh_tree(w, &coll, cat);
|
||
collections_ui::refresh_trash(w, cat);
|
||
}
|
||
}),
|
||
);
|
||
}
|
||
|
||
// TRACES: FR-MRG-1
|
||
{
|
||
let merge = merge_ui::MergeController::new(activity.clone());
|
||
let library_for_sources = library.clone();
|
||
let collections_for_sources = collections.clone();
|
||
let library_for_context = library.clone();
|
||
let library_for_done = library.clone();
|
||
merge_ui::wire(
|
||
window,
|
||
merge,
|
||
gpu.clone(),
|
||
move || library_for_sources.export_sources(&collections_for_sources.selected()),
|
||
move || {
|
||
let conn = library_for_context.session()?;
|
||
Some(merge_ui::Context {
|
||
outbox: export::outbox_dir(&conn.account),
|
||
conn,
|
||
})
|
||
},
|
||
move |w| {
|
||
// Staged beside its sources: upload it now rather than
|
||
// on the next sync pass, then look for it, exactly as an
|
||
// import does.
|
||
drain_outbox(&library_for_done);
|
||
w.global::<Library>().invoke_library_rescan();
|
||
},
|
||
);
|
||
}
|
||
|
||
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.global::<Library>().invoke_library_rescan();
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
/// The settings page: cache usage, the export folder picker, and the
|
||
/// callbacks a saved change has to ripple into the library controller.
|
||
fn wire_settings_screen(
|
||
window: &AppWindow,
|
||
settings: &Rc<settings_ui::SettingsController>,
|
||
library: &Rc<library_ui::LibraryController>,
|
||
) {
|
||
let settings = settings.clone();
|
||
let library = library.clone();
|
||
|
||
// What the cache actually holds, so the ceiling above it is a figure
|
||
// the user can judge rather than an abstract one.
|
||
//
|
||
// Empty at this point on a launch and that is expected: the catalog it
|
||
// queries is being opened on a worker (`library_ui::open_catalog_soon`)
|
||
// and is not there yet. The figure is filled in when the settings page
|
||
// is opened, which is the only time it is looked at — see the `on_open`
|
||
// closure passed to `settings_ui::wire`.
|
||
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);
|
||
|
||
// 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_fetch_ahead(stored.cache.fetch_ahead);
|
||
library.set_write_xmp_sidecars(stored.library.write_xmp_sidecars);
|
||
library.set_timeline_bars(stored.library.timeline_bars);
|
||
library.set_face_model_id(inference::model_id(stored.faces.detector));
|
||
}
|
||
|
||
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);
|
||
lib.set_fetch_ahead(s.cache.fetch_ahead);
|
||
lib.set_write_xmp_sidecars(s.library.write_xmp_sidecars);
|
||
if let Some(w) = weak.upgrade() {
|
||
refresh_export_label(&w);
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
}
|
||
|
||
// The pipeline the next sync and the next sweep run under, and
|
||
// the two lines on the page that describe it: which detector
|
||
// file is installed, and how much of the library that
|
||
// pipeline has covered — which for a freshly chosen one is
|
||
// nothing, and saying so is the point.
|
||
lib.set_face_model_id(inference::model_id(s.faces.detector));
|
||
if let Some(w) = weak.upgrade() {
|
||
refresh_face_status(&w, &lib, s.faces.detector);
|
||
}
|
||
|
||
// 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 ctl = settings.clone();
|
||
move |w: &AppWindow| {
|
||
refresh_face_status(w, &lib, ctl.snapshot().faces.detector);
|
||
// Here rather than at startup, on exactly the reasoning
|
||
// above: the catalog this reads is opened on a worker now,
|
||
// so a launch has nothing to describe, and the figure is
|
||
// only ever read while this page is on screen. `render`
|
||
// again because `settings_ui::wire` renders *before* it
|
||
// calls this, and the label is one of the things it draws.
|
||
ctl.set_usage_label(describe_cache_usage(&lib));
|
||
settings_ui::render(w, &ctl);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
/// Build the render closure: 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.
|
||
fn build_render_now(
|
||
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||
viewport: &Rc<RefCell<(u32, u32)>>,
|
||
display: &Rc<display_ui::DisplayWatch>,
|
||
chosen_peaking: &Rc<Cell<Option<dr_gpu::FocusPeaking>>>,
|
||
) -> Render {
|
||
let session = session.clone();
|
||
let viewport = viewport.clone();
|
||
let display = display.clone();
|
||
let chosen_peaking = chosen_peaking.clone();
|
||
|
||
// 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));
|
||
// TRACES: FR-DEV-5
|
||
// And the snapshot list, compared by value rather than by a revision: it
|
||
// is a handful of rows, and a held comparison changes one of them
|
||
// without a step being taken.
|
||
let drawn_snapshots: Rc<RefCell<Option<Vec<SnapshotRow>>>> = Rc::new(RefCell::new(None));
|
||
|
||
{
|
||
let session = session.clone();
|
||
let viewport = viewport.clone();
|
||
let drawn_history = drawn_history.clone();
|
||
let drawn_snapshots = drawn_snapshots.clone();
|
||
let display = display.clone();
|
||
let chosen_peaking = chosen_peaking.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.
|
||
let steps = window.global::<Steps>();
|
||
steps.set_can_undo(s.can_undo());
|
||
steps.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));
|
||
steps.set_rows(slint::ModelRc::new(slint::VecModel::from(s.history_rows())));
|
||
steps.set_undo_label(s.undo_label().into());
|
||
}
|
||
// TRACES: FR-DEV-17
|
||
// The notice about a crop that stranded a mask, on the path every
|
||
// history move takes, so an undo that takes the crop back takes
|
||
// the notice with it in the same frame.
|
||
develop_ui::sync_crop_notice(window, s);
|
||
// TRACES: FR-DEV-5
|
||
// The snapshots, on the same path. Rebuilt only when the list
|
||
// would read differently, for the reason the steps are: a held
|
||
// comparison redraws, and it is the one thing here that changes
|
||
// a row without changing the history.
|
||
let snapshots = s.snapshot_rows();
|
||
if drawn_snapshots.borrow().as_ref() != Some(&snapshots) {
|
||
*drawn_snapshots.borrow_mut() = Some(snapshots.clone());
|
||
steps.set_snapshots(slint::ModelRc::new(slint::VecModel::from(snapshots)));
|
||
}
|
||
|
||
// 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);
|
||
|
||
// TRACES: FR-CULL-3
|
||
// The session owns the pass and the interface owns the choice, so
|
||
// they are joined here — on the one path every frame takes, which
|
||
// is also what makes a photograph opened with peaking already on
|
||
// arrive with its marks rather than without them.
|
||
if s.peaking() != chosen_peaking.get() {
|
||
s.set_peaking(chosen_peaking.get());
|
||
}
|
||
window
|
||
.global::<Peaking>()
|
||
.set_available(s.peaking_available());
|
||
|
||
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);
|
||
}
|
||
|
||
// TRACES: FR-DEV-7
|
||
// Three renders of one graph, chosen here because this is the one
|
||
// path every frame takes — so a comparison held while a slider is
|
||
// still settling shows the original at full resolution too, rather
|
||
// than reverting the moment anything else asks for a redraw.
|
||
//
|
||
// 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.global::<Develop>().get_view_mode() == ViewMode::Crop {
|
||
s.render_uncropped(w, h).map(|(image, _, _)| image)
|
||
} else if window.get_showing_original() {
|
||
s.render_original(w, h)
|
||
} else if s.compared_snapshot().is_some() {
|
||
// TRACES: FR-DEV-7
|
||
// The same hold, against a snapshot rather than the file.
|
||
s.render_compared(w, h)
|
||
} else {
|
||
s.render(w, h)
|
||
};
|
||
|
||
match rendered {
|
||
Ok(image) => {
|
||
// TRACES: FR-DSP-4
|
||
// **The draft flag goes past the render**, to the two
|
||
// places that have to know which kind of frame is up: the
|
||
// canvas, which crossfades a draft into the sharp frame
|
||
// that settles it, and the histogram, which is measured
|
||
// on settled frames only and so describes an older frame
|
||
// while a draft is showing.
|
||
//
|
||
// The draft is kept as the fade's starting picture. That
|
||
// is a refcount on the texture it was drawn into, not a
|
||
// copy or a render — see `canvas-previous` for why the
|
||
// sharp frame cannot overwrite it.
|
||
if draft {
|
||
window.set_canvas_previous(image.clone());
|
||
}
|
||
window.set_canvas(image);
|
||
window.set_canvas_draft(draft);
|
||
window.global::<Levels>().set_provisional(draft);
|
||
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.global::<Levels>().set_data(
|
||
s.histogram()
|
||
.as_ref()
|
||
.map_or_else(histogram::empty, histogram::view),
|
||
);
|
||
|
||
// **The raw reading, pushed on the same path but not
|
||
// recomputed on it.** `DevelopSession::raw_histogram`
|
||
// caches: the demosaiced source is fixed for the life
|
||
// of the session, so this costs one dispatch per
|
||
// photograph and a clone of four kilobytes per settled
|
||
// frame thereafter. It is pushed from here anyway
|
||
// rather than once at open, because this is the one
|
||
// path every photograph takes and a panel that had to
|
||
// be told separately would be empty on some route into
|
||
// develop mode.
|
||
//
|
||
// Three outcomes, said apart. A file that was never
|
||
// raw has no white level and so no scale to measure
|
||
// headroom against; a device that could not build the
|
||
// reduction has a fault worth reporting; and only the
|
||
// third is a reading.
|
||
let raw = if !s.has_sensor_data() {
|
||
histogram::raw_empty(histogram::RawAbsence::NotRaw)
|
||
} else {
|
||
s.raw_histogram().as_ref().map_or_else(
|
||
|| histogram::raw_empty(histogram::RawAbsence::NoDevice),
|
||
histogram::raw_view,
|
||
)
|
||
};
|
||
window.global::<Levels>().set_raw_data(raw);
|
||
}
|
||
|
||
// TRACES: FR-CULL-3 | NFR-P14
|
||
// **Marked on the settled frame and no other**, and unlike
|
||
// the histogram beside it the marks are taken *down* in
|
||
// between rather than left standing.
|
||
//
|
||
// The reason is not budget — the dispatch is a fraction of
|
||
// a millisecond and would fit inside a draft frame
|
||
// comfortably. It is that peaking measures the top octave
|
||
// of the frame it is given, and a draft frame is rendered
|
||
// at half resolution: a defocused edge that spans four
|
||
// pixels there spans two, which is the signature of a
|
||
// sharp one. Measuring it would mark the out-of-focus
|
||
// background of every photograph, briefly, during every
|
||
// drag. A stale overlay is no better, because a pan moves
|
||
// the picture out from under it.
|
||
//
|
||
// So the marks pause while a control is moving and return
|
||
// when it stops, which the panel says out loud rather than
|
||
// leaving to be discovered.
|
||
let overlay = (!draft).then(|| s.focus_overlay()).flatten();
|
||
match overlay {
|
||
Some(image) => {
|
||
window.set_focus_overlay(image);
|
||
window.set_focus_overlay_ready(true);
|
||
}
|
||
None => window.set_focus_overlay_ready(false),
|
||
}
|
||
}
|
||
Err(e) => {
|
||
log::warn!("render failed: {e}");
|
||
window.set_load_error(e.into());
|
||
window.set_canvas_draft(false);
|
||
window.global::<Levels>().set_provisional(false);
|
||
// No frame, so nothing to describe. The stale plot would
|
||
// otherwise sit beside the error message looking current.
|
||
window.global::<Levels>().set_data(histogram::empty());
|
||
// **The raw reading is left standing, and that is not an
|
||
// oversight.** It describes the sensor data behind the
|
||
// session, which a failed *render* says nothing about: the
|
||
// demosaic succeeded or there would be no session at all.
|
||
// Emptying it here would take away the one instrument
|
||
// still telling the truth, at the moment the other one
|
||
// stopped.
|
||
// TRACES: FR-CULL-3
|
||
// And nothing to mark. Focus marks over the last frame
|
||
// that rendered, beside a message saying this one did not,
|
||
// is the same confident lie in a second instrument.
|
||
window.set_focus_overlay_ready(false);
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
/// Post exactly one render onto the event loop per burst of requests.
|
||
///
|
||
/// **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.
|
||
fn build_redraw(render_now: &Render) -> Rc<dyn Fn(&AppWindow)> {
|
||
let render_now = render_now.clone();
|
||
// Which frame to draw and when to settle, decided apart from the timers
|
||
// that carry it out — see `refine` for the rules and their tests.
|
||
let refine = Rc::new(RefCell::new(refine::Refine::default()));
|
||
Rc::new(move |window: &AppWindow| {
|
||
if !refine.borrow_mut().request() {
|
||
return;
|
||
}
|
||
|
||
let weak = window.as_weak();
|
||
let render_now = render_now.clone();
|
||
let refine = refine.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 || {
|
||
let Some(window) = weak.upgrade() else { return };
|
||
let Some((frame, settle)) = refine.borrow_mut().tick() else {
|
||
return;
|
||
};
|
||
render_now(&window, frame == refine::Frame::Draft);
|
||
let Some(token) = settle else { return };
|
||
|
||
// TRACES: FR-DSP-4
|
||
// One timer per draft frame, and only the newest is honoured, so
|
||
// the sharp frame waits for the gesture to stop rather than for
|
||
// the first draft to age. A superseded timer wakes, finds its
|
||
// token stale, and does nothing — cheaper than holding a `Timer`
|
||
// to restart, and it keeps the rule where the tests can see it.
|
||
let weak = window.as_weak();
|
||
slint::Timer::single_shot(SETTLE_DELAY, move || {
|
||
let Some(window) = weak.upgrade() else { return };
|
||
if refine.borrow_mut().settle(token).is_some() {
|
||
render_now(&window, false);
|
||
}
|
||
});
|
||
});
|
||
})
|
||
}
|
||
|
||
/// Build `show`: load the entry at `index` and put it on screen.
|
||
///
|
||
/// The local half of the two load routes view-composition.md describes —
|
||
/// `wire_remote_open` is the other, fetching bytes from the server instead of
|
||
/// reading a path — and each runs the same post-load sequence independently.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn build_show(
|
||
entries: &Rc<RefCell<Vec<PathBuf>>>,
|
||
index: &Rc<RefCell<usize>>,
|
||
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||
redraw: &Rc<dyn Fn(&AppWindow)>,
|
||
gpu: &Option<dr_gpu::GpuContext>,
|
||
rows: &Rc<slint::VecModel<ParamRow>>,
|
||
open_image: &presets::OpenImage,
|
||
library: &Rc<library_ui::LibraryController>,
|
||
viewport: &Rc<RefCell<(u32, u32)>>,
|
||
inspection: &Rc<Cell<Inspection>>,
|
||
) -> Rc<dyn Fn(&AppWindow)> {
|
||
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 = library.clone();
|
||
let viewport = viewport.clone();
|
||
let inspection = inspection.clone();
|
||
{
|
||
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();
|
||
let viewport_for_show = viewport.clone();
|
||
let inspection_for_show = inspection.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(), dr_decode::default(), path) {
|
||
Ok(l) => {
|
||
window.set_load_error("".into());
|
||
let capture = window.global::<Capture>();
|
||
capture.set_camera(describe_camera(&l.meta).into());
|
||
capture.set_exposure(describe_exposure(&l.meta).into());
|
||
capture.set_dimensions(format!("{} × {}", l.width, l.height).into());
|
||
|
||
// Composed by the session rather than here, because only it
|
||
// knows whether the lookup found anything — the header can
|
||
// name a lens the database has never heard of, which is the
|
||
// ordinary case for third-party and adapted glass and must
|
||
// read as a fact rather than as a failure.
|
||
capture.set_lens(
|
||
l.session
|
||
.as_ref()
|
||
.map(|s| s.lens_summary())
|
||
.unwrap_or_default()
|
||
.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.global::<Develop>().set_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);
|
||
}
|
||
|
||
// TRACES: FR-UI-4
|
||
// And under the same magnifier as the frame
|
||
// before it, if one was up. After the sidecar,
|
||
// because the point is a fraction of the framed
|
||
// image and the sidecar is what decides how the
|
||
// frame is cropped.
|
||
resume_inspection(&session, &viewport_for_show, &inspection_for_show);
|
||
|
||
// 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.global::<Develop>().set_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.global::<Develop>().set_enabled(false);
|
||
window.set_load_error(e.into());
|
||
let capture = window.global::<Capture>();
|
||
capture.set_camera("".into());
|
||
capture.set_exposure("".into());
|
||
capture.set_dimensions("".into());
|
||
capture.set_lens("".into());
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
/// 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.
|
||
///
|
||
/// The other half of the two load routes view-composition.md describes; see
|
||
/// [`build_show`] for the local one.
|
||
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||
fn wire_remote_open(
|
||
window: &AppWindow,
|
||
open_from_library: &Rc<RefCell<Option<Rc<dyn Fn(String)>>>>,
|
||
library: &Rc<library_ui::LibraryController>,
|
||
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||
redraw: &Rc<dyn Fn(&AppWindow)>,
|
||
rows: &Rc<slint::VecModel<ParamRow>>,
|
||
gpu: &Option<dr_gpu::GpuContext>,
|
||
activity: &Rc<activity::ActivityLog>,
|
||
open_image: &presets::OpenImage,
|
||
viewport: &Rc<RefCell<(u32, u32)>>,
|
||
inspection: &Rc<Cell<Inspection>>,
|
||
prefetch: &Rc<library::Prefetcher>,
|
||
) {
|
||
let open_from_library = open_from_library.clone();
|
||
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();
|
||
let viewport = viewport.clone();
|
||
let inspection = inspection.clone();
|
||
let prefetch = prefetch.clone();
|
||
|
||
// Which open is the current one. Every open starts its own download and
|
||
// its own timer, and a walk along the roll starts one per press — so the
|
||
// transfers land in whatever order the network finishes them, not the
|
||
// order they were asked for. Without this the photograph stepped past
|
||
// three presses ago could arrive last and replace the one whose name is
|
||
// on screen, and its edit would then be saved under that name.
|
||
let current = Rc::new(Cell::new(0u64));
|
||
|
||
{
|
||
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();
|
||
let viewport = viewport.clone();
|
||
let inspection = inspection.clone();
|
||
let prefetch = prefetch.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);
|
||
|
||
// And let the outgoing session go now, not when the download
|
||
// lands. Kept, its sliders stayed live over a photograph that was
|
||
// no longer the open one, and a second step before the first
|
||
// landed saved *that* session's edit under the new photograph's
|
||
// identity, which is set a few lines below.
|
||
*session.borrow_mut() = None;
|
||
rows.set_vec(Vec::<ParamRow>::new());
|
||
w.global::<Develop>().set_enabled(false);
|
||
|
||
current.set(current.get() + 1);
|
||
let mine = current.get();
|
||
|
||
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());
|
||
let capture = w.global::<Capture>();
|
||
capture.set_camera("".into());
|
||
capture.set_exposure("".into());
|
||
capture.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");
|
||
|
||
// TRACES: FR-NC-6a
|
||
// The grid's thumbnail stands in from this moment, so the step
|
||
// lands on this photograph rather than on the last one's pixels
|
||
// or an empty frame. Whether to say anything about a download is
|
||
// decided below, once one is actually running.
|
||
let (preview, size) = library.preview_for_path(&w, &path);
|
||
if let Some(p) = preview {
|
||
w.set_load_preview(p);
|
||
w.set_has_load_preview(true);
|
||
}
|
||
w.set_load_pending(true);
|
||
|
||
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 prefetch = prefetch.clone();
|
||
let path = path.clone();
|
||
let viewport = viewport.clone();
|
||
let inspection = inspection.clone();
|
||
let current = current.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 {
|
||
// TRACES: FR-NC-6a
|
||
// Still coming. Said only once the original is on the
|
||
// wire — a read from the cache lands before there is
|
||
// a transfer to find, and so never flashes a headline.
|
||
// Read by path, because the transfer is as often the
|
||
// prefetcher's as this open's own.
|
||
if current.get() == mine {
|
||
if let (Some(w), Some((received, declared))) =
|
||
(weak.upgrade(), library::transfer_progress(&path))
|
||
{
|
||
let (text, fraction) =
|
||
activity::describe_download(received, declared.or(size));
|
||
w.set_load_waiting(text.as_str().into());
|
||
w.set_load_fraction(fraction);
|
||
job.detail(text);
|
||
}
|
||
}
|
||
return;
|
||
};
|
||
// Landed — this timer has done its job.
|
||
held.stop();
|
||
let Some(w) = weak.upgrade() else { return };
|
||
|
||
// Stepped past while it was coming down. The transfer
|
||
// still counts — it is in the cache, and the step back
|
||
// will be a disk read — but it is not what is on screen.
|
||
if current.get() != mine {
|
||
match &got {
|
||
Ok(b) => job.finish(activity::describe_bytes(b.len() as u64)),
|
||
Err(e) => job.fail(e.message.clone()),
|
||
}
|
||
log::debug!("{name}: landed after the view moved on");
|
||
return;
|
||
}
|
||
w.set_load_pending(false);
|
||
w.set_load_waiting("".into());
|
||
w.set_has_load_preview(false);
|
||
|
||
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());
|
||
|
||
// TRACES: FR-NC-6a | FR-UI-4
|
||
// Now, and not when the click happened: started earlier
|
||
// they would share the connection with the download the
|
||
// user is watching, and the photograph on screen is the
|
||
// one that matters. Before the decode below, so the
|
||
// transfers are under way while the UI thread is busy.
|
||
prefetch_neighbours(&library, &prefetch, &path);
|
||
|
||
match load_bytes(gpu.as_ref(), dr_decode::default(), &bytes) {
|
||
Ok(l) => {
|
||
w.set_load_error("".into());
|
||
let capture = w.global::<Capture>();
|
||
capture.set_camera(describe_camera(&l.meta).into());
|
||
capture.set_exposure(describe_exposure(&l.meta).into());
|
||
capture.set_dimensions(format!("{} × {}", l.width, l.height).into());
|
||
match l.session {
|
||
Some(mut s) => {
|
||
w.global::<Develop>().set_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,
|
||
);
|
||
// TRACES: FR-UI-4
|
||
// Under the same magnifier as the last
|
||
// frame, on the terms `resume_inspection`
|
||
// sets out. The sidecar may still be in
|
||
// flight here, unlike the local path — so
|
||
// a photograph whose stored crop lands a
|
||
// moment later is inspected against the
|
||
// uncropped frame until it does, which
|
||
// moves the point rather than losing it.
|
||
resume_inspection(&session, &viewport, &inspection);
|
||
sync_rows(&w, &rows, &session);
|
||
redraw(&w);
|
||
}
|
||
None => {
|
||
*session.borrow_mut() = None;
|
||
rows.set_vec(Vec::<ParamRow>::new());
|
||
w.global::<Develop>().set_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.global::<Develop>().set_enabled(false);
|
||
w.set_load_error(e.into());
|
||
}
|
||
}
|
||
},
|
||
);
|
||
}));
|
||
}
|
||
}
|
||
|
||
/// The window chrome shared by every view: canvas resize (which reaches the
|
||
/// develop viewport), which display the window is on, the layout class that
|
||
/// picks compact versus expanded, the two collapsible columns, and the
|
||
/// platform's back gesture.
|
||
fn wire_shell(
|
||
window: &AppWindow,
|
||
viewport: &Rc<RefCell<(u32, u32)>>,
|
||
redraw: &Rc<dyn Fn(&AppWindow)>,
|
||
display: &Rc<display_ui::DisplayWatch>,
|
||
) {
|
||
let viewport = viewport.clone();
|
||
let redraw = redraw.clone();
|
||
let display = display.clone();
|
||
|
||
// 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());
|
||
// N6: what the last window line that carried a measurement reported.
|
||
// Shared with the resize callback below, which is where every reading
|
||
// after the first one arrives. See `window_metrics_level`.
|
||
let reported_geometry = std::rc::Rc::new(std::cell::Cell::new(None::<(bool, u32)>));
|
||
// Where `column-below` stood a moment ago, which is the other half of the
|
||
// hysteresis in `column_below`. Not in `PanelChoices`: that records where
|
||
// the *user* disagreed with a layout class, and this is neither a class nor
|
||
// anything the user has said — it is the last answer, kept so the next one
|
||
// can decline to contradict it over a pixel of drag.
|
||
let dock = std::rc::Rc::new(std::cell::Cell::new(None::<bool>));
|
||
{
|
||
let weak = window.as_weak();
|
||
let panels = panels.clone();
|
||
let reported_geometry = reported_geometry.clone();
|
||
let dock = dock.clone();
|
||
window.on_window_resized(move |width, height| {
|
||
let Some(window) = weak.upgrade() else { return };
|
||
apply_layout_class(&window, width, height, &panels, &dock);
|
||
log_window_metrics(&window, window_metrics_level(&window, &reported_geometry));
|
||
});
|
||
}
|
||
{
|
||
let size = window.window().size();
|
||
let scale = window.window().scale_factor().max(0.01);
|
||
apply_layout_class(
|
||
window,
|
||
size.width as f32 / scale,
|
||
size.height as f32 / scale,
|
||
&panels,
|
||
&dock,
|
||
);
|
||
// N6: the reading beside the one the layout class is derived from,
|
||
// which on a desktop is taken before the window has been mapped and so
|
||
// reads zero. `window_metrics_level` is what decides whether that is
|
||
// worth `info`; here it will not be, and the first resize carries the
|
||
// measurement instead.
|
||
log_window_metrics(window, window_metrics_level(window, &reported_geometry));
|
||
}
|
||
|
||
// 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)
|
||
});
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a
|
||
/// TRACES: FR-NC-6a | FR-UI-4
|
||
/// Ask for the photographs around `path` — as many each side as the settings
|
||
/// say — to be fetched into the cache.
|
||
///
|
||
/// Every gate the prefetcher documents is applied here, because this is the
|
||
/// side that can see the settings and the network: nothing while offline, and
|
||
/// nothing into a cache that would not keep the bytes. A neighbour the cache
|
||
/// cannot place — a row that has scrolled out of the window — is left out
|
||
/// rather than fetched to nowhere.
|
||
///
|
||
/// Called on every landing, including one with no neighbours: an empty wish
|
||
/// cancels whatever the previous photograph asked for, which is right — those
|
||
/// were *its* neighbours.
|
||
fn prefetch_neighbours(
|
||
library: &Rc<library_ui::LibraryController>,
|
||
prefetch: &library::Prefetcher,
|
||
path: &str,
|
||
) {
|
||
let Some(conn) = library.credentials() else {
|
||
return;
|
||
};
|
||
let jobs: Vec<library::PrefetchJob> = if library.is_offline() {
|
||
Vec::new()
|
||
} else {
|
||
library
|
||
.neighbours_of(path, library.fetch_ahead())
|
||
.into_iter()
|
||
.filter_map(|path| {
|
||
let cache = library.cache_context(&path).filter(|c| c.store)?;
|
||
Some(library::PrefetchJob { path, cache })
|
||
})
|
||
.collect()
|
||
};
|
||
if !jobs.is_empty() {
|
||
log::debug!("fetching {} neighbour(s) of {path} ahead", jobs.len());
|
||
}
|
||
prefetch.want(conn, jobs);
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a
|
||
/// Show the prefetcher's transfers in the activity list while they run.
|
||
///
|
||
/// A row each, like the download the user is waiting on, so a transfer that
|
||
/// is using the connection is never invisible. Removed rather than kept when
|
||
/// it ends, on the same grounds as a thumbnail batch: routine work the user
|
||
/// never asked for by name would push a failed transfer out of the list. A
|
||
/// prefetch that fails says nothing here — the click that wants the
|
||
/// photograph will try again and report in its own row.
|
||
fn show_prefetches(prefetch: &Rc<library::Prefetcher>, activity: &Rc<activity::ActivityLog>) {
|
||
let prefetch = prefetch.clone();
|
||
let activity = activity.clone();
|
||
let mut live: std::collections::HashMap<String, activity::Activity> = Default::default();
|
||
// Held by its own closure for the life of the window, like every other
|
||
// drain here.
|
||
let timer = Rc::new(slint::Timer::default());
|
||
let held = timer.clone();
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
std::time::Duration::from_millis(200),
|
||
move || {
|
||
let _keep = &held;
|
||
for event in prefetch.poll() {
|
||
match event {
|
||
library::PrefetchEvent::Started(path) => {
|
||
let name = path.rsplit('/').next().unwrap_or(&path);
|
||
let job = activity
|
||
.begin(activity::Kind::Download, format!("Fetching {name} ahead"));
|
||
live.insert(path, job);
|
||
}
|
||
library::PrefetchEvent::Ended(path) => {
|
||
if let Some(job) = live.remove(&path) {
|
||
job.finish_quietly();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 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.
|
||
/// TRACES: FR-CULL-8
|
||
/// The two face lines on the settings page: whether the chosen detector's file
|
||
/// is installed, and how far the chosen pipeline has got through the library.
|
||
///
|
||
/// One function because they move together. A detector picked from the list
|
||
/// changes which file has to exist *and* which `model_id` the coverage is
|
||
/// counted under, and a page that updated one without the other would report
|
||
/// "no model installed" over a coverage figure for a different model.
|
||
fn refresh_face_status(
|
||
window: &AppWindow,
|
||
library: &Rc<library_ui::LibraryController>,
|
||
detector: dr_types::FaceDetector,
|
||
) {
|
||
let store = library
|
||
.session()
|
||
.and_then(|c| dr_thumbs::ThumbStore::open(&library::thumbs_dir(&c.account)).ok());
|
||
let models = library
|
||
.session()
|
||
.and_then(|c| library::face_models(&c.account, detector));
|
||
identity_ui::refresh_coverage(
|
||
window,
|
||
&library.catalog(),
|
||
store.as_ref(),
|
||
inference::model_id(detector),
|
||
models.as_ref().is_some_and(|m| m.eyes.is_some()),
|
||
);
|
||
window.set_identity_model_missing(models.is_none());
|
||
}
|
||
|
||
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>>,
|
||
}
|
||
|
||
/// TRACES: FR-UI-1 | FR-UI-7
|
||
/// Where the adjustment groups are chosen from, for this device and this
|
||
/// photographer.
|
||
///
|
||
/// **Not the layout class, and not derived from one.** `apply_layout_class`
|
||
/// below answers "how much room is there" from the window's width, and was
|
||
/// right to refuse a device check — a narrow desktop window wants the compact
|
||
/// layout exactly as a small screen would. This answers "what is the user
|
||
/// pointing with", which width cannot stand in for: DarkRoom's two targets are
|
||
/// a 12-inch tablet and a desktop, the same size and the same layout class,
|
||
/// and only one of them has no hover, no modifier keys and a finger for a
|
||
/// cursor.
|
||
///
|
||
/// `ui-navigation.md` D-N2 decided against any divergence between the targets
|
||
/// and this is the exception to it, argued on D-N2's own ground: it identified
|
||
/// input as the real difference and then assumed touch changes hit regions
|
||
/// rather than layout. A horizontal strip that pans, at the top of a column,
|
||
/// against a rail of finger-sized targets down the edge the hand is already
|
||
/// on, is where that assumption runs out.
|
||
fn groups_in_rail(settings: &settings_ui::SettingsController) -> bool {
|
||
settings
|
||
.snapshot()
|
||
.develop
|
||
.group_navigation
|
||
.groups_in_rail(dr_plat::is_touch_first())
|
||
}
|
||
|
||
/// TRACES: FR-UI-1
|
||
/// How loudly to say the window's size, given what has already been said.
|
||
///
|
||
/// Three constraints meet here and only one arrangement satisfies all of them.
|
||
///
|
||
/// *It has to reach the device.* `android_main` caps the `log` facade at
|
||
/// `info` (`darkroom-android`), so anything at `debug` is discarded before it
|
||
/// reaches liblog and `adb logcat` never sees it. N6's whole deliverable is a
|
||
/// number read off a tablet, so a reading worth having must be `info`.
|
||
///
|
||
/// *It must not be the shape of a drag.* Dragging a desktop window emits a
|
||
/// resize per frame, and every accepted record is also appended to the on-disk
|
||
/// log (NFR-OPS-1). Hundreds of `info` lines saying the window is a pixel
|
||
/// wider is not a diagnostic, so a resize that only moves the size is `debug`.
|
||
///
|
||
/// *A window has no size until it has been mapped, and the size it reports
|
||
/// first is not the one it settles on.* The startup call runs before the event
|
||
/// loop, where `size()` is 0 x 0 and `scale_factor()` is 1.0; the first resize
|
||
/// after it arrives at whatever the platform opened with and at scale 1.0,
|
||
/// because the display's real scale is applied a beat later. Watched on X11
|
||
/// the sequence is 0 x 0, then 360 x 320 at 1.0, then 1100 x 720 at 2.0 — and
|
||
/// a rule that reports only the first of those puts a number in logcat that
|
||
/// is not the window's.
|
||
///
|
||
/// So the pair that decides is **the scale factor and the orientation**, which
|
||
/// is exactly the pair N6 is asking for and exactly the pair a drag leaves
|
||
/// alone. A window with no area is not a reading at all and records nothing.
|
||
/// Every later change to either half — the display's scale resolving, the
|
||
/// window moving to a monitor with a different density, the tablet being
|
||
/// turned over — is a new answer to N6's question and goes out at `info`; the
|
||
/// rest is `debug`. Scale is compared as the line prints it, to two decimals,
|
||
/// so two records that would read identically cannot both claim `info`.
|
||
fn window_metrics_level(
|
||
window: &AppWindow,
|
||
reported: &std::cell::Cell<Option<(bool, u32)>>,
|
||
) -> log::Level {
|
||
let size = window.window().size();
|
||
if size.width == 0 || size.height == 0 {
|
||
return log::Level::Debug;
|
||
}
|
||
let geometry = (
|
||
size.height >= size.width,
|
||
(window.window().scale_factor().max(0.01) * 100.0).round() as u32,
|
||
);
|
||
if reported.replace(Some(geometry)) == Some(geometry) {
|
||
log::Level::Debug
|
||
} else {
|
||
log::Level::Info
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-UI-1
|
||
/// The window's own measurements, named plainly enough to be read out of
|
||
/// `adb logcat`.
|
||
///
|
||
/// `ui-navigation.md` N6. Every figure in D-N7's table was computed at a
|
||
/// *guessed* scale factor, because nothing in this repository has ever
|
||
/// recorded the density Android reports: the tablet's panel is 3000 x 1920
|
||
/// physical, and the two plausible densities put the dock's width at 900 or
|
||
/// 1037 and its available height 200px apart. The only way to settle it is to
|
||
/// ask the window on the device and read the answer off the wire.
|
||
///
|
||
/// Both coordinate systems, on one line, so they can be checked against each
|
||
/// other. Logical alone cannot be trusted when the scale is the thing in
|
||
/// doubt, and a physical size that does not divide into the logical one by the
|
||
/// scale reported beside it says the reading is of something other than the
|
||
/// panel — an emulator, a scaled-down desktop window, a display that changed
|
||
/// under the application.
|
||
fn log_window_metrics(window: &AppWindow, level: log::Level) {
|
||
let physical = window.window().size();
|
||
// The same floor `apply_layout_class`'s caller uses: a scale of zero is
|
||
// not a number this can divide by, and a window that reports one is
|
||
// reporting nothing useful anyway.
|
||
let scale = window.window().scale_factor().max(0.01);
|
||
log::log!(
|
||
level,
|
||
"window: {:.0}x{:.0} logical at scale {scale:.2} ({}x{} physical)",
|
||
physical.width as f32 / scale,
|
||
physical.height as f32 / scale,
|
||
physical.width,
|
||
physical.height
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-UI-1
|
||
/// Which side of the photograph the develop column belongs on, given the shape
|
||
/// of the window and where the answer stood before (D-N7).
|
||
///
|
||
/// **Not a layout class.** The class is decided by width and says how much room
|
||
/// there is; this is decided by aspect and says which way round the room is. A
|
||
/// 960-wide portrait window is expanded and wants the dock; a 1500-wide
|
||
/// landscape one is expanded and does not — so the two cannot be folded
|
||
/// together, and `PanelChoices` does not remember this one. Closing the column
|
||
/// in landscape closes the dock in portrait, because it is the same column.
|
||
///
|
||
/// `previously` is `None` on the first call, before any resize has been
|
||
/// reported. There is nothing to be hysteretic about yet, so the entering
|
||
/// threshold decides alone: a window that opens inside the dead band opens with
|
||
/// the column beside the photograph, which is what every window that is not
|
||
/// clearly tall gets.
|
||
fn column_below(width: f32, height: f32, previously: Option<bool>) -> bool {
|
||
// A window with no width has no aspect. It happens between a minimise and
|
||
// the resize that follows it, and answering `false` here would swing the
|
||
// column back beside the photograph for the one frame in between.
|
||
if width <= 0.0 {
|
||
return previously.unwrap_or(false);
|
||
}
|
||
let aspect = height / width;
|
||
match previously {
|
||
Some(true) => aspect > DOCK_LEAVE_ASPECT,
|
||
_ => aspect >= DOCK_ENTER_ASPECT,
|
||
}
|
||
}
|
||
|
||
fn apply_layout_class(
|
||
window: &AppWindow,
|
||
width: f32,
|
||
height: f32,
|
||
panels: &PanelChoices,
|
||
dock: &std::cell::Cell<Option<bool>>,
|
||
) {
|
||
let expanded = width >= EXPANDED_MIN_WIDTH;
|
||
window.set_expanded(expanded);
|
||
window.set_layout_class(if expanded { "expanded" } else { "compact" }.into());
|
||
|
||
// FR-UI-1: which axis the column is on, set from here for the same reason
|
||
// the class is — the develop view's frame reads it, so deriving it from
|
||
// `root.width` inside that frame would be the binding loop the note on
|
||
// `expanded` in `app.slint` describes.
|
||
let below = column_below(width, height, dock.get());
|
||
dock.set(Some(below));
|
||
window.set_column_below(below);
|
||
|
||
// FR-UI-1: 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 {
|
||
// TRACES: FR-UI-5 | FR-UI-4
|
||
// The Identity screen has no representation in `NavState`: nothing here
|
||
// records which view it replaced, only `identity_ui`'s own
|
||
// `came_from_library`. So back leaves it the way its own "‹ Library" /
|
||
// "‹ Develop" button does, through `identity-close`, which knows where it
|
||
// came from — rather than through the general case below, which would
|
||
// write `active-view` without that knowledge. It used to swallow the key
|
||
// and do nothing, which left Escape and Android's Back dead on a whole
|
||
// screen.
|
||
if w.get_active_view() == View::Identity {
|
||
w.invoke_identity_close();
|
||
return true;
|
||
}
|
||
|
||
// TRACES: FR-CAT-11a | FR-UI-5
|
||
// The duplicates review is a page over whatever view opened it, and its
|
||
// own Back button knows how to leave it; back takes the same route.
|
||
if w.get_active_page() == Page::Duplicates {
|
||
w.global::<Duplicates>().invoke_close();
|
||
return true;
|
||
}
|
||
|
||
let state = NavState {
|
||
settings: w.get_active_page() == Page::Settings,
|
||
launch: w.get_active_view() == View::Launch,
|
||
browsing: w.get_launch_browsing(),
|
||
library: w.get_active_view() == View::Library,
|
||
mode: w.global::<Develop>().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.global::<Library>().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),
|
||
// TRACES: FR-UI-4
|
||
// Through the toggle rather than through `zoom_reset`, because a
|
||
// zoomed view is now something the photographer is *holding* across
|
||
// photographs: putting it down has to put the magnifier down too, or
|
||
// the next frame would open under a magnification that the back
|
||
// gesture had just dismissed.
|
||
BackStep::ResetZoom => w.invoke_inspect_toggled(-1.0, -1.0),
|
||
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
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_window_taller_than_it_is_wide_docks_the_column_below() {
|
||
// The tablet in portrait at either scale factor D-N7 considers, and a
|
||
// desktop window dragged into the same shape. Aspect, not width: the
|
||
// first of these is expanded by width and still wants the dock.
|
||
assert!(column_below(960.0, 1500.0, None));
|
||
assert!(column_below(1097.0, 1714.0, None));
|
||
assert!(column_below(700.0, 1100.0, Some(false)));
|
||
}
|
||
|
||
#[test]
|
||
fn a_window_wider_than_it_is_tall_keeps_the_column_beside() {
|
||
// Landscape, square, and the tablet's other orientation. The last is
|
||
// the one that matters: rotating back has to bring the column back.
|
||
assert!(!column_below(1500.0, 900.0, Some(false)));
|
||
assert!(!column_below(1000.0, 1000.0, Some(true)));
|
||
assert!(!column_below(1500.0, 960.0, Some(true)));
|
||
}
|
||
|
||
#[test]
|
||
fn a_window_resized_across_square_does_not_flap() {
|
||
// The dead band, from both sides. Between 1.15 and 1.25 the answer is
|
||
// whatever it already was, which is the whole reason there are two
|
||
// thresholds rather than one — a diagonal drag crosses this range for
|
||
// as long as the pointer is down.
|
||
let width = 1000.0;
|
||
for aspect in [1.16, 1.20, 1.24] {
|
||
let height = width * aspect;
|
||
assert!(
|
||
column_below(width, height, Some(true)),
|
||
"{aspect} from below"
|
||
);
|
||
assert!(
|
||
!column_below(width, height, Some(false)),
|
||
"{aspect} from beside"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_first_measurement_has_no_state_to_be_hysteretic_about() {
|
||
// Before any resize has been reported there is no previous answer, so
|
||
// the entering threshold decides alone and the dead band reads as
|
||
// "beside" — the arrangement every window that is not clearly tall
|
||
// gets. And a window with no width at all keeps whatever it had, so a
|
||
// minimise does not swing the column across for one frame.
|
||
assert!(!column_below(1000.0, 1200.0, None));
|
||
assert!(column_below(1000.0, 1250.0, None));
|
||
assert!(!column_below(0.0, 1200.0, None));
|
||
assert!(column_below(0.0, 400.0, Some(true)));
|
||
}
|
||
}
|