A copy becomes a duplicate only once its capture time is read, and on a fresh library that is the sweep, not the scan: the sidebar row stayed hidden until the next launch. The count is refreshed when a sweep that dated anything finishes. Also says on a group left out of the plan that nothing will move, drops an unused method, and names the review's completion callback type for clippy.
3854 lines
165 KiB
Rust
3854 lines
165 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;
|
||
#[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;
|
||
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;
|
||
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) {
|
||
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,
|
||
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.
|
||
fn refresh_export_label(window: &AppWindow, settings: &Rc<settings_ui::SettingsController>) {
|
||
let stored = settings.snapshot();
|
||
let remote = stored.export.target == dr_types::ExportTarget::Remote;
|
||
window.set_export_label(
|
||
if remote {
|
||
// Not the connector's name: this is a Nextcloud account for some
|
||
// libraries and a folder on a mount for others.
|
||
"Export to the library"
|
||
} else {
|
||
"Export"
|
||
}
|
||
.into(),
|
||
);
|
||
// The grid's button says the same thing about a selection, but it composes
|
||
// its own label around a count — so it is given the fact rather than the
|
||
// sentence (FR-EXP-7).
|
||
window.set_export_to_server(remote);
|
||
}
|
||
|
||
/// Push current parameter values back to the interface.
|
||
///
|
||
/// The controls are not self-updating: the core clamps values, so what the
|
||
/// user dragged to and what the parameter became can differ, and the control
|
||
/// must show the latter.
|
||
/// **Updates rows in place; never replaces the model.** Assigning a fresh
|
||
/// `ModelRc` tears down and rebuilds every row element — including the
|
||
/// `TouchArea` currently tracking the pointer — which cancels the drag in
|
||
/// progress. The symptom is a slider that jumps on click but cannot be
|
||
/// dragged, because each move event destroys the thing that would deliver
|
||
/// the next one.
|
||
/// TRACES: FR-CAT-8
|
||
/// Apply a fetched sidecar to the open session, now or as soon as it arrives.
|
||
///
|
||
/// The sidecar fetch is started beside the image fetch and is three orders of
|
||
/// magnitude smaller, so it has almost always landed by the time there is a
|
||
/// session to apply it to — and this takes it straight from the channel. The
|
||
/// timer covers the case where it has not, which is why this is not simply a
|
||
/// blocking receive: a slow or stalled sidecar request must not freeze the
|
||
/// window with the photograph already decoded and on screen.
|
||
///
|
||
/// A late arrival redraws, so the image is correct either way; the only
|
||
/// difference is whether it was ever briefly shown at its defaults.
|
||
fn apply_when_ready(
|
||
window: &AppWindow,
|
||
rx: Rc<std::sync::mpsc::Receiver<Option<dr_pipeline::Sidecar>>>,
|
||
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||
rows: &Rc<slint::VecModel<ParamRow>>,
|
||
redraw: &Rc<dyn Fn(&AppWindow)>,
|
||
) {
|
||
// Already here — the overwhelmingly common case.
|
||
if let Ok(got) = rx.try_recv() {
|
||
if let Some(sidecar) = got {
|
||
presets::apply_stored_edit(window, &sidecar, session, rows);
|
||
}
|
||
return;
|
||
}
|
||
|
||
let weak = window.as_weak();
|
||
let session = session.clone();
|
||
let rows = rows.clone();
|
||
let redraw = redraw.clone();
|
||
let timer = Rc::new(slint::Timer::default());
|
||
let held = timer.clone();
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
std::time::Duration::from_millis(50),
|
||
move || {
|
||
let Ok(got) = rx.try_recv() else { return };
|
||
held.stop();
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let Some(sidecar) = got else { return };
|
||
if presets::apply_stored_edit(&w, &sidecar, &session, &rows) {
|
||
redraw(&w);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3f
|
||
/// Push the film choice out to the panel.
|
||
///
|
||
/// Separate from [`sync_rows`] because the stock is not a row: it is not a
|
||
/// parameter, so it is not in the model the panel generates its controls from.
|
||
/// Called from the two handlers that change it, and once when an image opens.
|
||
pub(crate) fn sync_film(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>>) {
|
||
let choices = DevelopSession::film_choices();
|
||
|
||
let chosen = session
|
||
.borrow()
|
||
.as_ref()
|
||
.and_then(|s| s.film().map(|(stock, print)| (stock.to_string(), print)));
|
||
|
||
// A stock this build has no profile for leaves the picker on "None" rather
|
||
// than inventing an entry for it. The sidecar still carries the name — it
|
||
// is only the *control* that cannot show what it does not have.
|
||
let selected = chosen
|
||
.as_ref()
|
||
.and_then(|(stock, _)| {
|
||
choices
|
||
.iter()
|
||
.position(|(id, _)| *id == Some(stock.as_str()))
|
||
})
|
||
.unwrap_or(0);
|
||
|
||
// 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());
|
||
|
||
// 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-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());
|
||
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, &settings);
|
||
|
||
// Apply what is on disk before anything can use it. Without this the
|
||
// controller's defaults stand until the user happens to open the
|
||
// settings page and change something — so a cache deliberately capped
|
||
// at 2 GB last session would spend this one filling to the default.
|
||
{
|
||
let stored = settings.snapshot();
|
||
library.set_cache_budget(stored.cache.original_budget_bytes);
|
||
library.set_keep_opened_originals(stored.cache.keep_opened_originals);
|
||
library.set_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));
|
||
}
|
||
|
||
// --- the export folder picker ----------------------------------------
|
||
//
|
||
// Wired here rather than inside `settings_ui::wire` because listing a
|
||
// remote folder needs credentials, and the settings page deliberately
|
||
// holds no session — it is reachable before a library is opened and must
|
||
// not depend on one existing.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = settings.clone();
|
||
let library = library.clone();
|
||
|
||
// Every entry point needs the same three things, so they are fetched
|
||
// once here rather than at four call sites.
|
||
let start = move |ctl: &Rc<settings_ui::SettingsController>,
|
||
weak: &slint::Weak<AppWindow>,
|
||
library: &Rc<library_ui::LibraryController>,
|
||
path: String| {
|
||
match library.session() {
|
||
Some(conn) => {
|
||
settings_ui::spawn_folder_list(weak.clone(), ctl.clone(), conn, path);
|
||
}
|
||
None => {
|
||
// No account, so nothing to browse. Said plainly rather
|
||
// than left as an empty list, which would read as a server
|
||
// with no folders on it.
|
||
ctl.set_error("Sign in to a library before choosing a folder on it.");
|
||
ctl.browser.replace(None);
|
||
}
|
||
}
|
||
};
|
||
|
||
{
|
||
let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start);
|
||
window
|
||
.global::<ExportOptions>()
|
||
.on_browse_open_picker(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
// Opens on the library root rather than on whatever the
|
||
// destination field happens to contain: a half-typed path
|
||
// would list nothing and look like a broken picker.
|
||
ctl.browser.replace(Some(launch::FolderBrowser {
|
||
path: String::new(),
|
||
entries: Vec::new(),
|
||
loading: true,
|
||
}));
|
||
start(&ctl, &weak, &library, String::new());
|
||
settings_ui::render(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
{
|
||
let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start);
|
||
window
|
||
.global::<ExportOptions>()
|
||
.on_browse_into(move |name| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let path = {
|
||
let mut browser = ctl.browser.borrow_mut();
|
||
let Some(b) = browser.as_mut() else { return };
|
||
let path = b.child_path(&name);
|
||
b.path = path.clone();
|
||
b.entries.clear();
|
||
b.loading = true;
|
||
path
|
||
};
|
||
start(&ctl, &weak, &library, path);
|
||
settings_ui::render(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
{
|
||
let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start);
|
||
window.global::<ExportOptions>().on_browse_up(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let path = {
|
||
let mut browser = ctl.browser.borrow_mut();
|
||
let Some(b) = browser.as_mut() else { return };
|
||
let Some(path) = b.parent_path() else { return };
|
||
b.path = path.clone();
|
||
b.entries.clear();
|
||
b.loading = true;
|
||
path
|
||
};
|
||
start(&ctl, &weak, &library, path);
|
||
settings_ui::render(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
{
|
||
let (weak, ctl) = (weak.clone(), ctl.clone());
|
||
window.global::<ExportOptions>().on_browse_confirm(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
// The folder being *shown* is the one chosen, matching the
|
||
// library picker — so "use this one" means the same thing in
|
||
// both places rather than depending on a selection the list
|
||
// does not have.
|
||
let chosen = ctl.browser.borrow().as_ref().map(|b| b.path.clone());
|
||
if let Some(path) = chosen {
|
||
ctl.set_destination(path);
|
||
}
|
||
ctl.browser.replace(None);
|
||
settings_ui::render(&w, &ctl);
|
||
refresh_export_label(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
{
|
||
let (weak, ctl) = (weak.clone(), ctl.clone());
|
||
window.global::<ExportOptions>().on_browse_cancel(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
ctl.browser.replace(None);
|
||
settings_ui::render(&w, &ctl);
|
||
});
|
||
}
|
||
}
|
||
|
||
let lib = library.clone();
|
||
let ctl = settings.clone();
|
||
let weak = window.as_weak();
|
||
settings_ui::wire(
|
||
window,
|
||
settings.clone(),
|
||
move |s| {
|
||
// A ceiling that moved has to be applied to what is already on
|
||
// disk, or lowering it would only affect future downloads and the
|
||
// cache would sit over budget indefinitely.
|
||
lib.set_cache_budget(s.cache.original_budget_bytes);
|
||
lib.set_keep_opened_originals(s.cache.keep_opened_originals);
|
||
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, &ctl);
|
||
}
|
||
|
||
// The axis is cut into bars when the window is loaded, so a new
|
||
// count only shows once something reloads it. Done here, and only
|
||
// when the figure actually moved: a setting that appears to do
|
||
// nothing until the user scrolls is one they will change twice and
|
||
// then leave wrong.
|
||
if lib.set_timeline_bars(s.library.timeline_bars) {
|
||
if let Some(w) = weak.upgrade() {
|
||
library_ui::reload(&w, &lib);
|
||
}
|
||
}
|
||
|
||
// 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();
|
||
|
||
{
|
||
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);
|
||
|
||
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");
|
||
w.set_load_error("Downloading…".into());
|
||
|
||
let rx = library::spawn_full_fetch(conn, path.clone(), cache);
|
||
|
||
// The one transfer the user is actively waiting on. It gets a row
|
||
// like any other, so a download that is still running after they
|
||
// give up and go back to the grid is still accounted for.
|
||
//
|
||
// No denominator: `spawn_full_fetch` reports a result, not bytes as
|
||
// they arrive, so the honest bar here is the indeterminate one.
|
||
let job = activity.begin(activity::Kind::Download, format!("Downloading {name}"));
|
||
|
||
// Polled on the UI thread rather than joined: a join would freeze
|
||
// the window for the length of the download.
|
||
let weak = w.as_weak();
|
||
let session = session.clone();
|
||
let redraw = redraw.clone();
|
||
let rows = rows.clone();
|
||
let gpu = gpu.clone();
|
||
// Cloned for the timer closure: the outer callback is an `Fn` and
|
||
// may run again for the next photograph.
|
||
let library = library.clone();
|
||
let prefetch = prefetch.clone();
|
||
let path = path.clone();
|
||
let viewport = viewport.clone();
|
||
let inspection = inspection.clone();
|
||
let sidecar_rx = Rc::new(sidecar_rx);
|
||
let timer = Rc::new(slint::Timer::default());
|
||
let held = timer.clone();
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
std::time::Duration::from_millis(50),
|
||
move || {
|
||
let Ok(got) = rx.try_recv() else { return };
|
||
// Landed — this timer has done its job.
|
||
held.stop();
|
||
let Some(w) = weak.upgrade() else { return };
|
||
|
||
let bytes = match got {
|
||
Ok(b) => b,
|
||
Err(e) => {
|
||
job.fail(e.message.clone());
|
||
log::warn!("{name}: {e}");
|
||
// Offline needs its own words. "network error:
|
||
// connection refused" over a photograph the user
|
||
// just clicked reads as a broken app; the real
|
||
// situation is that this particular image was
|
||
// never stored on this device, and the fix is to
|
||
// download it while there is a connection.
|
||
w.set_load_error(if e.offline {
|
||
"Offline — this image is not stored on this device.".into()
|
||
} else {
|
||
slint::SharedString::from(e.message)
|
||
});
|
||
return;
|
||
}
|
||
};
|
||
job.finish(activity::describe_bytes(bytes.len() as u64));
|
||
log::info!("{name}: {} bytes fetched", bytes.len());
|
||
|
||
// 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)));
|
||
}
|
||
}
|