Every background job reported into a window property of its own — library-thumbs-done, library-pin-total, library-syncing — which only the grid ever read. A pin download that outlived the view it was started from drew nothing at all once the user opened an image, and there was no answer anywhere to "what is this busy with", because the answer was spread across eight properties nothing collected. They report to one register now (ui/dr-ui/src/activity.rs). It publishes an aggregate, which draws a three-pixel bar across the top of the shell in every view, and a row per job, which the settings page lists: scans, thumbnail batches, pin and open downloads, sidecar uploads, the sync and the trash. Failures stay on the list until they are cleared; routine successes do not, or a scroll would bury them. The handle removes a still-running job when it drops, so a worker that dies mid-transfer takes its row with it rather than leaving the bar sweeping for the rest of the session. Also carries in-flight work from a parallel session — the drawn icon set and the dr-pipeline ops split. dr-pipeline's build script does not compile at this commit; ui/dr-ui does, with clippy clean and its tests passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1785 lines
69 KiB
Rust
1785 lines
69 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.
|
||
//!
|
||
//! **Read before assuming A1 is proven.** Slint's public API for adopting an
|
||
//! externally created wgpu texture is not wired up here; this build uploads
|
||
//! through `SharedPixelBuffer`, which *is* a CPU round-trip — explicitly the
|
||
//! thing ARCH §6.1 forbids in production. Spike S1 replaces it. Until then A1
|
||
//! is unvalidated, and the develop path pays a readback per frame that the
|
||
//! finished one will not.
|
||
//!
|
||
//! **The develop panel is generated, not written.** [`develop`] asks the
|
||
//! pipeline what parameters it has and builds a control per answer; no code
|
||
//! in `ui/` names an operation or knows a shader exists (FR-DEV-3a).
|
||
|
||
mod activity;
|
||
mod collections_ui;
|
||
mod derived_sync;
|
||
mod develop;
|
||
mod labels;
|
||
mod library;
|
||
mod library_ui;
|
||
#[cfg(live_style)]
|
||
mod live_style;
|
||
mod net_runtime;
|
||
mod settings_store;
|
||
mod settings_ui;
|
||
mod trash;
|
||
|
||
use std::cell::RefCell;
|
||
use std::path::{Path, PathBuf};
|
||
use std::rc::Rc;
|
||
|
||
use anyhow::Result;
|
||
use dr_decode::{Metadata, PreviewSize};
|
||
|
||
pub use develop::DevelopSession;
|
||
|
||
pub mod launch;
|
||
pub mod launch_ui;
|
||
|
||
slint::include_modules!();
|
||
|
||
/// TRACES: FR-DSP-1 | NFR-RES-1
|
||
/// Longest edge the viewer renders at.
|
||
///
|
||
/// FR-DSP-1: work at the resolution the viewport needs, not the source
|
||
/// resolution. A 5472×3648 preview is 79.8 MB of RGBA; at 2048 it is 11 MB,
|
||
/// which is what keeps a folder browsable within NFR-RES-1's budget.
|
||
const MAX_DISPLAY_DIM: u32 = 2048;
|
||
|
||
/// TRACES: FR-UI-1 | FR-UI-2 | M-16
|
||
/// Width at which the expanded layout appears (FR-UI-1).
|
||
///
|
||
/// Logical pixels, not a device check — a narrow desktop window gets the
|
||
/// compact layout exactly as a tablet in portrait would.
|
||
const EXPANDED_MIN_WIDTH: f32 = 820.0;
|
||
|
||
/// How long after the last change a draft frame is replaced by a sharp one.
|
||
///
|
||
/// Above the interval between events in a drag, so an ordinary gesture never
|
||
/// reaches it and never renders full resolution mid-motion; well below the
|
||
/// point where a photographer would notice waiting for the sharp frame.
|
||
const SETTLE_DELAY: std::time::Duration = std::time::Duration::from_millis(120);
|
||
|
||
/// Re-render the current session into the canvas; `true` asks for a draft.
|
||
///
|
||
/// Shared rather than passed by reference because most of the callbacks in
|
||
/// `run` need it and they each outlive the call that built them, so every one
|
||
/// holds its own handle.
|
||
type Render = Rc<dyn Fn(&AppWindow, bool)>;
|
||
|
||
/// Everything loaded for the currently displayed image.
|
||
struct Loaded {
|
||
/// A develop session. `None` only where the file could not be opened for
|
||
/// editing at all — a body rawler cannot decode, or a corrupt JPEG — in
|
||
/// which case `fallback` carries an embedded preview and the adjust panel
|
||
/// is disabled rather than shown doing nothing.
|
||
session: Option<DevelopSession>,
|
||
fallback: Option<slint::Image>,
|
||
meta: Metadata,
|
||
width: u32,
|
||
height: u32,
|
||
}
|
||
|
||
/// Load one image, preferring the full develop path.
|
||
///
|
||
/// Reads the whole file: demosaic needs every photosite. The remote path
|
||
/// (FR-NC-3) fetches only a byte range for *browsing*, which is why the
|
||
/// preview API is separate — this is the develop path, and it is expected to
|
||
/// be expensive.
|
||
fn load(ctx: Option<&dr_gpu::GpuContext>, path: &Path) -> Result<Loaded, String> {
|
||
// A VFS placeholder holds one byte and reading it triggers no fetch
|
||
// (ARCH §9.0). Say so plainly rather than reporting a decode failure.
|
||
if path
|
||
.file_name()
|
||
.map(|n| n.to_string_lossy().ends_with(dr_types::PLACEHOLDER_SUFFIX))
|
||
.unwrap_or(false)
|
||
{
|
||
return Err("not downloaded — Nextcloud placeholder".into());
|
||
}
|
||
|
||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||
load_bytes(ctx, &bytes)
|
||
}
|
||
|
||
/// Open already-fetched bytes.
|
||
///
|
||
/// Split from [`load`] because a library image has no local file: it arrives
|
||
/// as a WebDAV response body, and writing it to disk purely to read it back
|
||
/// would be a round-trip for nothing.
|
||
fn load_bytes(ctx: Option<&dr_gpu::GpuContext>, bytes: &[u8]) -> Result<Loaded, String> {
|
||
let meta = dr_decode::metadata(bytes).unwrap_or_default();
|
||
|
||
// Route by what the bytes actually are, not by extension (M-9).
|
||
//
|
||
// A JPEG has no sensor data and never will, so trying the RAW decoder
|
||
// first would be a guaranteed failure whose log line reads like a fault.
|
||
// It goes straight to the RGB path instead, which is what makes develop
|
||
// mode work on the JPEGs the library already indexes.
|
||
let is_jpeg = dr_decode::probe(bytes) == Some(dr_types::Format::Jpeg);
|
||
|
||
// How the file stored its pixels. A file that says nothing is taken as
|
||
// upright — see `Orientation::from_exif`.
|
||
let orientation = meta.orientation.unwrap_or_default();
|
||
|
||
if let Some(ctx) = ctx {
|
||
let opened = if is_jpeg {
|
||
dr_decode::decode_jpeg(bytes)
|
||
.map_err(|e| e.to_string())
|
||
.and_then(|mut p| {
|
||
// Fit the device before uploading. A film scan runs to
|
||
// 13728×8928, well past the 8192 a typical GPU can hold,
|
||
// and refusing it would drop the image back to a
|
||
// read-only preview — the very thing this path exists to
|
||
// avoid. 8192 is still four times a 4K long edge.
|
||
let limit = dr_gpu::DemosaicedImage::max_dimension(ctx);
|
||
if p.width.max(p.height) > limit {
|
||
log::info!(
|
||
"{}×{} exceeds the {limit} texture limit; fitting to it",
|
||
p.width,
|
||
p.height
|
||
);
|
||
p.downscale_to(limit);
|
||
}
|
||
DevelopSession::open_rgb(ctx, &p.rgba, p.width, p.height, orientation)
|
||
})
|
||
} else {
|
||
// A failure here is expected for bodies rawler does not know, and
|
||
// must not stop the image displaying (FR-RAW-4).
|
||
dr_decode::decode(bytes)
|
||
.map_err(|e| e.to_string())
|
||
.and_then(|raw| DevelopSession::open(ctx, &raw, orientation))
|
||
};
|
||
|
||
match opened {
|
||
Ok(session) => {
|
||
let (width, height) = session.source_size();
|
||
return Ok(Loaded {
|
||
session: Some(session),
|
||
fallback: None,
|
||
meta,
|
||
width,
|
||
height,
|
||
});
|
||
}
|
||
Err(e) => log::info!("develop unavailable, showing preview: {e}"),
|
||
}
|
||
}
|
||
|
||
// Fall back to the embedded preview: no GPU, or a file neither decoder
|
||
// could open for editing. Read-only, and the adjust panel is disabled.
|
||
let mut preview =
|
||
dr_decode::extract_preview(bytes, PreviewSize::Screen).map_err(|e| e.to_string())?;
|
||
preview.downscale_to(MAX_DISPLAY_DIM);
|
||
// No graph here to carry the baseline, so the pixels are turned instead.
|
||
// Cheaper than it sounds after the downscale, and this path is the one a
|
||
// phone without a working GPU lands on — where sideways is most likely.
|
||
preview.apply_orientation(orientation);
|
||
|
||
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(
|
||
&preview.rgba,
|
||
preview.width,
|
||
preview.height,
|
||
);
|
||
|
||
Ok(Loaded {
|
||
session: None,
|
||
fallback: Some(slint::Image::from_rgba8(buffer)),
|
||
meta,
|
||
width: preview.width,
|
||
height: preview.height,
|
||
})
|
||
}
|
||
|
||
/// Collect displayable images from file or directory arguments.
|
||
fn collect(paths: &[PathBuf]) -> Vec<PathBuf> {
|
||
let mut out = Vec::new();
|
||
for p in paths {
|
||
if p.is_dir() {
|
||
let Ok(entries) = std::fs::read_dir(p) else {
|
||
continue;
|
||
};
|
||
let mut found: Vec<PathBuf> = entries
|
||
.flatten()
|
||
.map(|e| e.path())
|
||
.filter(|p| p.is_file() && is_supported(p))
|
||
.collect();
|
||
found.sort();
|
||
out.extend(found);
|
||
} else if p.is_file() && is_supported(p) {
|
||
out.push(p.clone());
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Whether a path names an image DarkRoom can catalogue.
|
||
///
|
||
/// Includes VFS placeholders: `IMG.CR2.nextcloud` is an image the user has,
|
||
/// just not locally (ARCH §9.0). Excluding it would make a synced folder look
|
||
/// empty rather than offline, which is the opposite of FR-NC-6c's intent.
|
||
fn is_supported(p: &Path) -> bool {
|
||
let name = p
|
||
.file_name()
|
||
.map(|n| n.to_string_lossy())
|
||
.unwrap_or_default();
|
||
let name = name
|
||
.strip_suffix(dr_types::PLACEHOLDER_SUFFIX)
|
||
.unwrap_or(&name);
|
||
name.rsplit_once('.')
|
||
.map(|(_, ext)| ext.to_ascii_lowercase())
|
||
.and_then(|e| dr_types::Format::from_extension(&e))
|
||
.is_some()
|
||
}
|
||
|
||
/// Return the view to its opening state for a newly loaded image.
|
||
///
|
||
/// Zoom and crop mode are properties of *looking at one photograph*, so
|
||
/// carrying them to the next one would leave the second image cropped to a
|
||
/// rect chosen for the first.
|
||
fn reset_view_state(window: &AppWindow) {
|
||
window.set_crop_mode(false);
|
||
window.set_zoom(1.0);
|
||
window.set_zoomed(false);
|
||
window.set_crop_x(0.0);
|
||
window.set_crop_y(0.0);
|
||
window.set_crop_w(1.0);
|
||
window.set_crop_h(1.0);
|
||
window.set_max_straighten(dr_pipeline::framing::MAX_STRAIGHTEN);
|
||
window.set_straighten(0.0);
|
||
window.set_flip_h(false);
|
||
window.set_flip_v(false);
|
||
window.set_framing_modified(false);
|
||
}
|
||
|
||
/// Push the framing back to the geometry panel.
|
||
///
|
||
/// Separate from [`sync_rows`] because framing is no longer *in* the rows —
|
||
/// it is presented by its own panel rather than generated (see
|
||
/// `DevelopSession::rows`), so nothing else would carry these values across.
|
||
///
|
||
/// Everything here is written from what the session actually holds rather than
|
||
/// from what the gesture asked for: quarter turns wrap, the angle is clamped
|
||
/// to the descriptor's range, and the crop is normalised, so the panel must
|
||
/// show the applied value or it will disagree with the image.
|
||
fn sync_framing(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>>) {
|
||
let Some(s) = session.borrow().as_ref().map(|s| {
|
||
let (h, v) = s.flips();
|
||
let c = s.crop();
|
||
(s.angle(), h, v, s.framing_edits_image(), c)
|
||
}) else {
|
||
return;
|
||
};
|
||
let (angle, flip_h, flip_v, modified, crop) = s;
|
||
window.set_straighten(angle);
|
||
window.set_flip_h(flip_h);
|
||
window.set_flip_v(flip_v);
|
||
window.set_framing_modified(modified);
|
||
// The overlay draws from these, and a rotation re-expresses the rect —
|
||
// so they have to follow a quarter turn even though no handle moved.
|
||
window.set_crop_x(crop.x);
|
||
window.set_crop_y(crop.y);
|
||
window.set_crop_w(crop.width);
|
||
window.set_crop_h(crop.height);
|
||
}
|
||
|
||
/// 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.
|
||
fn sync_rows(
|
||
window: &AppWindow,
|
||
rows: &Rc<slint::VecModel<ParamRow>>,
|
||
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||
) {
|
||
use slint::Model as _;
|
||
|
||
// Framing is not in `rows` — it has its own panel — but it *is* parameter
|
||
// state that the core may have clamped, so it is pushed back here for the
|
||
// same reason and by the same callers. A `reset all` reaches the framing
|
||
// too, and without this the geometry panel would keep showing the angle
|
||
// and flips of an image that no longer has them.
|
||
sync_framing(window, session);
|
||
|
||
let current = match session.borrow().as_ref() {
|
||
Some(s) => s.rows(),
|
||
None => Vec::new(),
|
||
};
|
||
|
||
// Whether the drawn curve has to be resampled. Sampling runs the spline 96
|
||
// times and builds a fresh model, and `sync_rows` is called on *every*
|
||
// parameter event — so doing it unconditionally spent that on every
|
||
// exposure or contrast drag, none of which can change the curve's shape.
|
||
// Only a moved point can, and the in-place update below is what knows.
|
||
let mut curve_moved = false;
|
||
|
||
if current.len() == rows.row_count() {
|
||
for (i, mut row) in current.into_iter().enumerate() {
|
||
let existing = rows.row_data(i);
|
||
|
||
// A curve row carries a *nested* model of point coordinates, and
|
||
// `rows()` builds a fresh one each call. Swapping it in would
|
||
// destroy the point elements — including the `TouchArea` holding
|
||
// the current drag — so the existing model is kept and its values
|
||
// written through instead.
|
||
//
|
||
// It also makes the equality test below meaningful: `ModelRc`
|
||
// compares by identity, so a brand-new points model would make
|
||
// every curve row look changed on every event.
|
||
if let Some(previous) = existing.as_ref() {
|
||
match update_points_in_place(&previous.points, &row.points) {
|
||
PointsUpdate::Moved => {
|
||
curve_moved = true;
|
||
row.points = previous.points.clone();
|
||
}
|
||
PointsUpdate::Unchanged => row.points = previous.points.clone(),
|
||
PointsUpdate::Incompatible => {}
|
||
}
|
||
}
|
||
|
||
// Only touch rows that actually changed, so unrelated controls
|
||
// are not needlessly invalidated.
|
||
if existing.as_ref() != Some(&row) {
|
||
rows.set_row_data(i, row);
|
||
}
|
||
}
|
||
} else {
|
||
// A different image, so the control set itself changed. Rebuilding
|
||
// is correct here — there is no drag to preserve, and the new image's
|
||
// curve must be drawn whatever shape it is in.
|
||
rows.set_vec(current);
|
||
curve_moved = true;
|
||
}
|
||
|
||
if !curve_moved {
|
||
return;
|
||
}
|
||
|
||
let samples = match session.borrow().as_ref() {
|
||
Some(s) => s.curve_samples(),
|
||
None => Vec::new(),
|
||
};
|
||
|
||
// The drawn curve follows the points. Replacing this model wholesale is
|
||
// safe where replacing `rows` was not: nothing in it is a drag target.
|
||
window.set_curve_samples(slint::ModelRc::new(slint::VecModel::from(samples)));
|
||
}
|
||
|
||
/// Copy `fresh`'s values into `existing`, keeping the model identity.
|
||
///
|
||
/// Returns `false` where the two differ in length, in which case the caller
|
||
/// must take the new model wholesale — the control set itself has changed and
|
||
/// there is no drag worth preserving.
|
||
fn update_points_in_place(
|
||
existing: &slint::ModelRc<f32>,
|
||
fresh: &slint::ModelRc<f32>,
|
||
) -> PointsUpdate {
|
||
use slint::Model as _;
|
||
|
||
if existing.row_count() != fresh.row_count() {
|
||
return PointsUpdate::Incompatible;
|
||
}
|
||
let mut moved = false;
|
||
for i in 0..fresh.row_count() {
|
||
let (Some(new), Some(old)) = (fresh.row_data(i), existing.row_data(i)) else {
|
||
continue;
|
||
};
|
||
// Guarded so an unchanged coordinate does not invalidate its element
|
||
// — the same reasoning as the row-level check above.
|
||
if new != old {
|
||
existing.set_row_data(i, new);
|
||
moved = true;
|
||
}
|
||
}
|
||
if moved {
|
||
PointsUpdate::Moved
|
||
} else {
|
||
PointsUpdate::Unchanged
|
||
}
|
||
}
|
||
|
||
/// What [`update_points_in_place`] found, which decides two things: whether the
|
||
/// existing points model can be kept, and whether the drawn curve needs
|
||
/// resampling.
|
||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||
enum PointsUpdate {
|
||
/// Lengths differ. The caller must take the fresh model wholesale — the
|
||
/// control set itself changed and there is no drag worth preserving.
|
||
Incompatible,
|
||
/// At least one coordinate was written through.
|
||
Moved,
|
||
/// Every coordinate already matched.
|
||
Unchanged,
|
||
}
|
||
|
||
/// TRACES: 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());
|
||
|
||
let window = AppWindow::new()?;
|
||
|
||
// 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));
|
||
|
||
// Before anything binds to a token: the compiled palette is already in
|
||
// place, so this only overwrites what style.yaml currently says.
|
||
#[cfg(live_style)]
|
||
live_style::apply(&window);
|
||
|
||
// The library grid: scan the remote tree into the catalog, then show what
|
||
// was found. Clicking a cell opens it in develop.
|
||
//
|
||
// Declared out here rather than inside the launch block below because the
|
||
// develop side reads `paths` to rebuild its browsing list when an image is
|
||
// opened from the grid.
|
||
let library = library_ui::LibraryController::new(activity.clone());
|
||
|
||
// Launch screen: shown when there is nothing to display — no local paths
|
||
// and no configured library. A user who has already signed in and chosen
|
||
// a folder goes straight to their images (FR-NC-1).
|
||
{
|
||
let controller = launch_ui::LaunchController::new();
|
||
let startup = controller.model.borrow().startup_action(!paths.is_empty());
|
||
window.set_show_launch(startup == launch::Startup::ShowLaunchScreen);
|
||
|
||
let library = library.clone();
|
||
let collections = collections_ui::CollectionsController::new(activity.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();
|
||
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);
|
||
});
|
||
|
||
// The collections sidebar shares the library's catalog handle rather
|
||
// than opening its own: one SQLite connection, so an edit here is
|
||
// visible to the grid's next read without a reopen.
|
||
//
|
||
// The reload closure is the seam between the two controllers. The
|
||
// sidebar decides *what* is scoped; the library owns the window, the
|
||
// offset and the thumbnail workers, so it is what actually reloads —
|
||
// and it must be told the scope before it reads, which is why both
|
||
// happen here in one place rather than each controller reaching for the
|
||
// other.
|
||
{
|
||
let weak = window.as_weak();
|
||
let lib = library.clone();
|
||
let coll = collections.clone();
|
||
let lib_ids = library.clone();
|
||
let lib_session = library.clone();
|
||
collections_ui::wire(
|
||
&window,
|
||
collections.clone(),
|
||
library.catalog(),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
// Order matters: `set_scope` clears the trash flag, because
|
||
// picking a collection is how you leave the trash. Setting
|
||
// the flag second is what lets selecting the trash itself
|
||
// survive the call.
|
||
lib.set_scope(coll.scope());
|
||
lib.set_viewing_trash(coll.viewing_trash());
|
||
library_ui::reload(&w, &lib);
|
||
},
|
||
move || lib_ids.visible_ids(),
|
||
// The trash's MOVE and DELETE go to the same account the scan
|
||
// and thumbnail workers use.
|
||
move || lib_session.session(),
|
||
);
|
||
}
|
||
|
||
let weak = window.as_weak();
|
||
let store_ctl = controller.clone();
|
||
let lib = library.clone();
|
||
let coll = collections.clone();
|
||
launch_ui::wire(&window, controller.clone(), move |session| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
log::info!("opening library for {}", session.describe());
|
||
library_ui::open(&w, lib.clone(), coll.clone(), &store_ctl.store, session);
|
||
});
|
||
|
||
match startup {
|
||
launch::Startup::ShowLaunchScreen => {
|
||
log::info!("no library configured — showing the launch screen");
|
||
}
|
||
launch::Startup::ShowLocalFiles => {
|
||
log::info!("{} file(s) named on the command line", paths.len());
|
||
}
|
||
// Skipping the launch screen must not mean skipping the library:
|
||
// the "Open library" button lives on the screen we just bypassed,
|
||
// so nothing else would ever start the scan.
|
||
launch::Startup::OpenLibrary => {
|
||
let session = controller.model.borrow().session().cloned();
|
||
if let Some(session) = session {
|
||
log::info!("resuming library for {}", session.describe());
|
||
library_ui::open(
|
||
&window,
|
||
library.clone(),
|
||
collections.clone(),
|
||
&controller.store,
|
||
session,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Settings: cache ceilings and export defaults, in their own config file.
|
||
//
|
||
// Wired independently of every view above. It reads no library and holds no
|
||
// session, so it has nothing to be sequenced against — which is the reason
|
||
// it is a page reachable from anywhere rather than a panel inside one view.
|
||
{
|
||
let settings = settings_ui::SettingsController::new();
|
||
// What the cache actually holds, so the ceiling above it is a figure
|
||
// the user can judge rather than an abstract one.
|
||
settings.set_usage_label(describe_cache_usage(&library));
|
||
// Rendered once up front so the page is correct the first time it is
|
||
// opened, rather than on the second open after a callback has run.
|
||
settings_ui::render(&window, &settings);
|
||
|
||
// 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);
|
||
}
|
||
|
||
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);
|
||
|
||
// 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);
|
||
}
|
||
});
|
||
}
|
||
|
||
// The device is shared by demosaic and the adjust pass. Without one the
|
||
// app still browses through the preview path, just without develop.
|
||
let gpu = match pollster::block_on(dr_gpu::GpuContext::new_headless()) {
|
||
Ok(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());
|
||
Some(ctx)
|
||
}
|
||
Err(e) => {
|
||
log::warn!("no GPU adapter: {e}");
|
||
window.set_backend("NO GPU".into());
|
||
None
|
||
}
|
||
};
|
||
|
||
window.set_total(entries.borrow().len() as i32);
|
||
let index = Rc::new(RefCell::new(0usize));
|
||
// The current develop session, if the file yielded sensor data.
|
||
let session: Rc<RefCell<Option<DevelopSession>>> = Rc::new(RefCell::new(None));
|
||
|
||
// One model for the lifetime of the window. Rows are mutated in place;
|
||
// see `sync_rows` for why replacing it breaks dragging.
|
||
let rows: Rc<slint::VecModel<ParamRow>> = Rc::new(slint::VecModel::default());
|
||
window.set_adjust_rows(rows.clone().into());
|
||
// Viewport size, tracked so a re-render after a slider move matches it.
|
||
let viewport = Rc::new(RefCell::new((1024u32, 768u32)));
|
||
|
||
// Re-render the current session into the canvas.
|
||
//
|
||
// Called on every slider change, so it must do no more than run the
|
||
// adjust pass — the demosaic is not repeated.
|
||
let render_now: Render = {
|
||
let session = session.clone();
|
||
let viewport = viewport.clone();
|
||
Rc::new(move |window: &AppWindow, draft: bool| {
|
||
let mut slot = session.borrow_mut();
|
||
let Some(s) = slot.as_mut() else { return };
|
||
let (mut w, mut h) = *viewport.borrow();
|
||
|
||
// **Half resolution while the gesture is still moving.**
|
||
//
|
||
// The adjust pass and the readback both scale 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.
|
||
// A draft frame is visible for one gesture and is replaced by a
|
||
// full-resolution one the moment motion stops, so the cost is a
|
||
// little softness exactly while the image is moving too fast to
|
||
// study anyway.
|
||
if draft {
|
||
w = (w / 2).max(1);
|
||
h = (h / 2).max(1);
|
||
}
|
||
|
||
// Crop mode shows the whole frame, or the area being cropped away
|
||
// would not be on screen for the handles to drag across. The
|
||
// overlay draws the rect on top of it.
|
||
let rendered = if window.get_crop_mode() {
|
||
s.render_uncropped(w, h).map(|(image, _, _)| image)
|
||
} else {
|
||
s.render(w, h)
|
||
};
|
||
|
||
match rendered {
|
||
Ok(image) => {
|
||
window.set_canvas(image);
|
||
window.set_load_error("".into());
|
||
// The readout and the "Fit" button follow the session
|
||
// rather than the gesture, so a clamped zoom shows the
|
||
// value that was actually applied.
|
||
window.set_zoom(s.zoom());
|
||
window.set_zoomed(s.is_zoomed());
|
||
// Filtering follows the magnification, measured against the
|
||
// *full* viewport rather than `w`/`h`: a draft frame is
|
||
// rendered at half resolution, and letting that flip the
|
||
// canvas to smooth would make it change character for the
|
||
// duration of every gesture.
|
||
let (vw, vh) = *viewport.borrow();
|
||
window.set_magnified(s.magnifies_source(vw, vh));
|
||
}
|
||
Err(e) => {
|
||
log::warn!("render failed: {e}");
|
||
window.set_load_error(e.into());
|
||
}
|
||
}
|
||
})
|
||
};
|
||
|
||
// **Rendering is decoupled from input, and this is why.**
|
||
//
|
||
// A render is a blocking GPU round-trip (see `AdjustPass::read_output`).
|
||
// Running one straight from a `moved` handler put that stall *inside* the
|
||
// gesture: touch events arrive far faster than a render completes, so the
|
||
// input queue backed up, positions arrived stale, and Android — seeing the
|
||
// events go unconsumed — reclaimed the gesture and delivered `cancel`
|
||
// instead of `up`. That is the dropped-drag bug, and no amount of tuning
|
||
// inside the Slint handlers fixes it while the stall is on the input path.
|
||
//
|
||
// So `redraw` no longer renders. It marks the canvas dirty and posts a
|
||
// single render onto the event loop; every further request while one is
|
||
// already pending just sets the flag again. A drag emitting forty events
|
||
// therefore renders a handful of times instead of forty, and — the part
|
||
// that actually fixes the drop — each event handler returns immediately,
|
||
// so the gesture is always consumed promptly.
|
||
//
|
||
// The flag is re-checked *after* the render because parameters may have
|
||
// moved again while it ran; that repost is what keeps the image converging
|
||
// on the finger rather than settling on a stale frame.
|
||
let render_pending = Rc::new(std::cell::Cell::new(false));
|
||
let render_dirty = Rc::new(std::cell::Cell::new(false));
|
||
// Set while a settle render is already queued, so a burst of draft frames
|
||
// schedules exactly one of them rather than one apiece.
|
||
let settle_pending = Rc::new(std::cell::Cell::new(false));
|
||
// Whether a request had to be coalesced into one already queued — see the
|
||
// gesture note below, where this stands in for a drag boundary.
|
||
let was_coalesced = Rc::new(std::cell::Cell::new(false));
|
||
let redraw: Rc<dyn Fn(&AppWindow)> = {
|
||
let render_now = render_now.clone();
|
||
let render_pending = render_pending.clone();
|
||
let render_dirty = render_dirty.clone();
|
||
let settle_pending = settle_pending.clone();
|
||
let was_coalesced = was_coalesced.clone();
|
||
Rc::new(move |window: &AppWindow| {
|
||
render_dirty.set(true);
|
||
if render_pending.get() {
|
||
was_coalesced.set(true);
|
||
return;
|
||
}
|
||
render_pending.set(true);
|
||
|
||
let weak = window.as_weak();
|
||
let render_now = render_now.clone();
|
||
let render_pending = render_pending.clone();
|
||
let render_dirty = render_dirty.clone();
|
||
let settle_pending = settle_pending.clone();
|
||
let was_coalesced = was_coalesced.clone();
|
||
// A zero-delay `Timer` rather than `invoke_from_event_loop`: the
|
||
// latter demands `Send`, and every piece of state here is `Rc` on
|
||
// the UI thread by design. The delay being zero is the point — this
|
||
// is "after the queued input has drained", not a throttle.
|
||
slint::Timer::single_shot(std::time::Duration::ZERO, move || {
|
||
render_pending.set(false);
|
||
let Some(window) = weak.upgrade() else { return };
|
||
if !render_dirty.replace(false) {
|
||
return;
|
||
}
|
||
|
||
// **What counts as "still dragging".**
|
||
//
|
||
// No control reports a gesture boundary, and threading one out
|
||
// of every slider, curve point and crop handle would be a lot
|
||
// of surface for a rendering concern. `was_coalesced` answers
|
||
// it instead: it is set only when a request arrived while a
|
||
// render was already queued, which can only mean a control
|
||
// moved again — that is a drag. One-off changes — a click, a
|
||
// reset, a resize — never coalesce, so they render sharp the
|
||
// first time and never draw a draft frame at all.
|
||
let dragging = was_coalesced.replace(false);
|
||
render_now(&window, dragging);
|
||
if !dragging {
|
||
return;
|
||
}
|
||
|
||
if settle_pending.replace(true) {
|
||
return;
|
||
}
|
||
let weak = window.as_weak();
|
||
let render_now = render_now.clone();
|
||
let settle_pending = settle_pending.clone();
|
||
// Long enough that an ordinary drag never reaches it, short
|
||
// enough that the sharp frame feels immediate on release.
|
||
slint::Timer::single_shot(SETTLE_DELAY, move || {
|
||
settle_pending.set(false);
|
||
let Some(window) = weak.upgrade() else { return };
|
||
render_now(&window, false);
|
||
});
|
||
});
|
||
})
|
||
};
|
||
|
||
let show = {
|
||
let entries = entries.clone();
|
||
let index = index.clone();
|
||
let session = session.clone();
|
||
let redraw = redraw.clone();
|
||
let gpu = gpu.clone();
|
||
let rows = rows.clone();
|
||
Rc::new(move |window: &AppWindow| {
|
||
let i = *index.borrow();
|
||
// Cloned rather than held: `load` below is slow, and keeping the
|
||
// list borrowed across it would panic the moment anything else
|
||
// touched `entries`.
|
||
let Some(path) = entries.borrow().get(i).cloned() else {
|
||
return;
|
||
};
|
||
let path = path.as_path();
|
||
|
||
let name = path
|
||
.file_name()
|
||
.unwrap_or_default()
|
||
.to_string_lossy()
|
||
.to_string();
|
||
reset_view_state(window);
|
||
window.set_filename(name.clone().into());
|
||
window.set_index(i as i32);
|
||
|
||
match load(gpu.as_ref(), path) {
|
||
Ok(l) => {
|
||
window.set_load_error("".into());
|
||
window.set_camera(describe_camera(&l.meta).into());
|
||
window.set_exposure(describe_exposure(&l.meta).into());
|
||
window.set_dimensions(format!("{} × {}", l.width, l.height).into());
|
||
|
||
// The panel is built from what the pipeline reports, so
|
||
// this code names no operation (FR-DEV-3a).
|
||
match l.session {
|
||
Some(s) => {
|
||
window.set_adjust_enabled(true);
|
||
*session.borrow_mut() = Some(s);
|
||
// 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;
|
||
rows.set_vec(Vec::<ParamRow>::new());
|
||
window.set_adjust_enabled(false);
|
||
if let Some(image) = l.fallback {
|
||
window.set_canvas(image);
|
||
}
|
||
}
|
||
}
|
||
log::info!("{name}: {}×{}", l.width, l.height);
|
||
}
|
||
Err(e) => {
|
||
// A failure on one image must not stop browsing (FR-RAW-4).
|
||
log::warn!("{name}: {e}");
|
||
*session.borrow_mut() = None;
|
||
window.set_adjust_enabled(false);
|
||
window.set_load_error(e.into());
|
||
window.set_camera("".into());
|
||
window.set_exposure("".into());
|
||
window.set_dimensions("".into());
|
||
}
|
||
}
|
||
})
|
||
};
|
||
|
||
// Now `show` exists, close the knot left open at the library wiring.
|
||
//
|
||
// The grid's paths are *remote*: there is no local file to open, so the
|
||
// click starts a download and the image appears when it lands. That is a
|
||
// whole RAW file over WebDAV, so the wait is real and has to be visible —
|
||
// the status line says so rather than leaving a blank frame.
|
||
{
|
||
let weak = window.as_weak();
|
||
let library = library.clone();
|
||
let session = session.clone();
|
||
let redraw = redraw.clone();
|
||
let rows = rows.clone();
|
||
let gpu = gpu.clone();
|
||
let activity = activity.clone();
|
||
*open_from_library.borrow_mut() = Some(Rc::new(move |path: String| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
|
||
let name = path.rsplit('/').next().unwrap_or(&path).to_string();
|
||
reset_view_state(&w);
|
||
w.set_filename(name.clone().into());
|
||
w.set_load_error("".into());
|
||
w.set_camera("".into());
|
||
w.set_exposure("".into());
|
||
w.set_dimensions("".into());
|
||
// The grid is one image at a time, so next/previous have nothing
|
||
// to walk. Shown as 1 of 1 rather than left reading 0.
|
||
w.set_index(0);
|
||
w.set_total(1);
|
||
|
||
let Some((creds, user_id)) = library.credentials() else {
|
||
w.set_load_error("no library session".into());
|
||
return;
|
||
};
|
||
|
||
// TRACES: FR-NC-6a
|
||
// The cache is consulted first, so a second open of the same
|
||
// photograph is a disk read rather than a second download of tens
|
||
// of megabytes — and so a session's worth of images stays
|
||
// openable when the connection goes.
|
||
let cache = library.cache_context(&path);
|
||
if cache.is_none() {
|
||
log::debug!("no originals cache for {path}; fetching every time");
|
||
}
|
||
|
||
log::info!("fetching {path} for develop");
|
||
w.set_load_error("Downloading…".into());
|
||
|
||
let rx = library::spawn_full_fetch(creds, user_id, path.clone(), cache);
|
||
|
||
// The one transfer the user is actively waiting on. It gets a row
|
||
// like any other, so a download that is still running after they
|
||
// give up and go back to the grid is still accounted for.
|
||
//
|
||
// No denominator: `spawn_full_fetch` reports a result, not bytes as
|
||
// they arrive, so the honest bar here is the indeterminate one.
|
||
let job = activity.begin(activity::Kind::Download, format!("Downloading {name}"));
|
||
|
||
// Polled on the UI thread rather than joined: a join would freeze
|
||
// the window for the length of the download.
|
||
let weak = w.as_weak();
|
||
let session = session.clone();
|
||
let redraw = redraw.clone();
|
||
let rows = rows.clone();
|
||
let gpu = gpu.clone();
|
||
let timer = Rc::new(slint::Timer::default());
|
||
let held = timer.clone();
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
std::time::Duration::from_millis(50),
|
||
move || {
|
||
let Ok(got) = rx.try_recv() else { return };
|
||
// Landed — this timer has done its job.
|
||
held.stop();
|
||
let Some(w) = weak.upgrade() else { return };
|
||
|
||
let bytes = match got {
|
||
Ok(b) => b,
|
||
Err(e) => {
|
||
job.fail(e.message.clone());
|
||
log::warn!("{name}: {e}");
|
||
// Offline needs its own words. "network error:
|
||
// connection refused" over a photograph the user
|
||
// just clicked reads as a broken app; the real
|
||
// situation is that this particular image was
|
||
// never stored on this device, and the fix is to
|
||
// download it while there is a connection.
|
||
w.set_load_error(if e.offline {
|
||
"Offline — this image is not stored on this device.".into()
|
||
} else {
|
||
slint::SharedString::from(e.message)
|
||
});
|
||
return;
|
||
}
|
||
};
|
||
job.finish(activity::describe_bytes(bytes.len() as u64));
|
||
log::info!("{name}: {} bytes fetched", bytes.len());
|
||
|
||
match load_bytes(gpu.as_ref(), &bytes) {
|
||
Ok(l) => {
|
||
w.set_load_error("".into());
|
||
w.set_camera(describe_camera(&l.meta).into());
|
||
w.set_exposure(describe_exposure(&l.meta).into());
|
||
w.set_dimensions(format!("{} × {}", l.width, l.height).into());
|
||
match l.session {
|
||
Some(s) => {
|
||
w.set_adjust_enabled(true);
|
||
*session.borrow_mut() = Some(s);
|
||
sync_rows(&w, &rows, &session);
|
||
redraw(&w);
|
||
}
|
||
None => {
|
||
*session.borrow_mut() = None;
|
||
rows.set_vec(Vec::<ParamRow>::new());
|
||
w.set_adjust_enabled(false);
|
||
if let Some(image) = l.fallback {
|
||
w.set_canvas(image);
|
||
}
|
||
}
|
||
}
|
||
log::info!("{name}: {}×{}", l.width, l.height);
|
||
}
|
||
Err(e) => {
|
||
log::warn!("{name}: {e}");
|
||
*session.borrow_mut() = None;
|
||
w.set_adjust_enabled(false);
|
||
w.set_load_error(e.into());
|
||
}
|
||
}
|
||
},
|
||
);
|
||
}));
|
||
}
|
||
|
||
// ---- Adjustment callbacks ------------------------------------------
|
||
//
|
||
// Generic by construction: they carry indices into the capability list,
|
||
// so adding an operation needs no change here (FR-DEV-3c).
|
||
{
|
||
let weak = window.as_weak();
|
||
let session = session.clone();
|
||
let redraw = redraw.clone();
|
||
let rows = rows.clone();
|
||
window.on_param_changed(move |op, param, value| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if let Some(s) = session.borrow_mut().as_mut() {
|
||
s.set_param(op, param, value);
|
||
}
|
||
sync_rows(&w, &rows, &session);
|
||
redraw(&w);
|
||
});
|
||
}
|
||
{
|
||
let weak = window.as_weak();
|
||
let session = session.clone();
|
||
let redraw = redraw.clone();
|
||
let rows = rows.clone();
|
||
window.on_param_reset(move |op, param| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if let Some(s) = session.borrow_mut().as_mut() {
|
||
s.reset_param(op, param);
|
||
}
|
||
sync_rows(&w, &rows, &session);
|
||
redraw(&w);
|
||
});
|
||
}
|
||
{
|
||
let weak = window.as_weak();
|
||
let session = session.clone();
|
||
let redraw = redraw.clone();
|
||
let rows = rows.clone();
|
||
window.on_reset_all(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if let Some(s) = session.borrow_mut().as_mut() {
|
||
s.reset_all();
|
||
}
|
||
sync_rows(&w, &rows, &session);
|
||
redraw(&w);
|
||
});
|
||
}
|
||
{
|
||
// A curve is one control spanning many parameters, so resetting it
|
||
// clears all of them at once — resetting a single point would leave
|
||
// a shape the user did not ask for.
|
||
let weak = window.as_weak();
|
||
let session = session.clone();
|
||
let redraw = redraw.clone();
|
||
let rows = rows.clone();
|
||
window.on_curve_reset(move |op| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if let Some(s) = session.borrow_mut().as_mut() {
|
||
s.reset_curve(op);
|
||
}
|
||
sync_rows(&w, &rows, &session);
|
||
redraw(&w);
|
||
});
|
||
}
|
||
|
||
// ---- zoom, pan and crop ---------------------------------------------
|
||
//
|
||
// Zoom and pan are viewing state and touch no parameter, so unlike the
|
||
// handlers above they do not `sync_rows`.
|
||
{
|
||
let weak = window.as_weak();
|
||
let session = session.clone();
|
||
let redraw = redraw.clone();
|
||
window.on_zoom_at(move |factor, at_x, at_y| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if let Some(s) = session.borrow_mut().as_mut() {
|
||
s.zoom_about(factor, at_x, at_y);
|
||
}
|
||
redraw(&w);
|
||
});
|
||
}
|
||
{
|
||
let weak = window.as_weak();
|
||
let session = session.clone();
|
||
let redraw = redraw.clone();
|
||
window.on_pan_by(move |dx, dy| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if let Some(s) = session.borrow_mut().as_mut() {
|
||
s.pan_by(dx, dy);
|
||
}
|
||
redraw(&w);
|
||
});
|
||
}
|
||
{
|
||
let weak = window.as_weak();
|
||
let session = session.clone();
|
||
let redraw = redraw.clone();
|
||
window.on_zoom_reset(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if let Some(s) = session.borrow_mut().as_mut() {
|
||
s.reset_zoom();
|
||
}
|
||
redraw(&w);
|
||
});
|
||
}
|
||
{
|
||
// Entering crop mode drops the zoom: the handles are placed against
|
||
// the whole frame, and a zoomed view would put most of that frame off
|
||
// screen where it cannot be dragged.
|
||
let weak = window.as_weak();
|
||
let session = session.clone();
|
||
let redraw = redraw.clone();
|
||
window.on_crop_mode_toggled(move |on| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if let Some(s) = session.borrow_mut().as_mut() {
|
||
if on {
|
||
s.reset_zoom();
|
||
let c = s.crop();
|
||
w.set_crop_x(c.x);
|
||
w.set_crop_y(c.y);
|
||
w.set_crop_w(c.width);
|
||
w.set_crop_h(c.height);
|
||
}
|
||
}
|
||
w.set_crop_mode(on);
|
||
redraw(&w);
|
||
});
|
||
}
|
||
{
|
||
// The rect arrives raw from the drag; the session normalises it, and
|
||
// the properties are written back from what it actually stored. That
|
||
// round trip is what makes an over-drag slide along the edge rather
|
||
// than letting the overlay and the pipeline disagree.
|
||
let weak = window.as_weak();
|
||
let session = session.clone();
|
||
let redraw = redraw.clone();
|
||
window.on_crop_changed(move |x, y, width, height| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if let Some(s) = session.borrow_mut().as_mut() {
|
||
s.set_crop(dr_pipeline::CropRect {
|
||
x,
|
||
y,
|
||
width,
|
||
height,
|
||
});
|
||
let c = s.crop();
|
||
w.set_crop_x(c.x);
|
||
w.set_crop_y(c.y);
|
||
w.set_crop_w(c.width);
|
||
w.set_crop_h(c.height);
|
||
w.set_framing_modified(s.framing_edits_image());
|
||
}
|
||
redraw(&w);
|
||
});
|
||
}
|
||
|
||
// ---- rotation, flips and straightening -------------------------------
|
||
//
|
||
// Framing edits, so unlike zoom and pan they mark the image modified — but
|
||
// they are reached through named session actions rather than through a row
|
||
// index, so they do not `sync_rows` either. `sync_framing` is what carries
|
||
// the applied value back.
|
||
{
|
||
let weak = window.as_weak();
|
||
let session = session.clone();
|
||
let redraw = redraw.clone();
|
||
window.on_rotate_quarters(move |turns| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if let Some(s) = session.borrow_mut().as_mut() {
|
||
s.rotate_quarters(turns);
|
||
}
|
||
sync_framing(&w, &session);
|
||
redraw(&w);
|
||
});
|
||
}
|
||
{
|
||
let weak = window.as_weak();
|
||
let session = session.clone();
|
||
let redraw = redraw.clone();
|
||
window.on_flip_h_toggled(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if let Some(s) = session.borrow_mut().as_mut() {
|
||
s.toggle_flip_h();
|
||
}
|
||
sync_framing(&w, &session);
|
||
redraw(&w);
|
||
});
|
||
}
|
||
{
|
||
let weak = window.as_weak();
|
||
let session = session.clone();
|
||
let redraw = redraw.clone();
|
||
window.on_flip_v_toggled(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if let Some(s) = session.borrow_mut().as_mut() {
|
||
s.toggle_flip_v();
|
||
}
|
||
sync_framing(&w, &session);
|
||
redraw(&w);
|
||
});
|
||
}
|
||
{
|
||
let weak = window.as_weak();
|
||
let session = session.clone();
|
||
let redraw = redraw.clone();
|
||
window.on_straighten_changed(move |degrees| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if let Some(s) = session.borrow_mut().as_mut() {
|
||
s.set_angle(degrees);
|
||
}
|
||
sync_framing(&w, &session);
|
||
redraw(&w);
|
||
});
|
||
}
|
||
{
|
||
// The geometry section's reset: crop, angle, rotation and flips back
|
||
// to neutral, leaving every colour adjustment where it is. The panel's
|
||
// own reset-all is the one that clears everything.
|
||
let weak = window.as_weak();
|
||
let session = session.clone();
|
||
let redraw = redraw.clone();
|
||
window.on_framing_reset(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if let Some(s) = session.borrow_mut().as_mut() {
|
||
s.reset_framing();
|
||
}
|
||
sync_framing(&w, &session);
|
||
redraw(&w);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let index = index.clone();
|
||
let entries = entries.clone();
|
||
let show = show.clone();
|
||
window.on_next_image(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let len = entries.borrow().len();
|
||
if len == 0 {
|
||
return;
|
||
}
|
||
// Read, then write — `*x.borrow_mut() = *x.borrow() + 1` holds
|
||
// both borrows at once and panics.
|
||
let next = {
|
||
let cur = *index.borrow();
|
||
(cur + 1) % len
|
||
};
|
||
*index.borrow_mut() = next;
|
||
show(&w);
|
||
});
|
||
}
|
||
{
|
||
let weak = window.as_weak();
|
||
let index = index.clone();
|
||
let entries = entries.clone();
|
||
let show = show.clone();
|
||
window.on_prev_image(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let len = entries.borrow().len();
|
||
if len == 0 {
|
||
return;
|
||
}
|
||
let prev = {
|
||
let cur = *index.borrow();
|
||
if cur == 0 {
|
||
len - 1
|
||
} else {
|
||
cur - 1
|
||
}
|
||
};
|
||
*index.borrow_mut() = prev;
|
||
show(&w);
|
||
});
|
||
}
|
||
|
||
// Track the canvas size so the adjust pass renders at viewport
|
||
// resolution rather than sensor resolution (FR-DSP-1).
|
||
{
|
||
let weak = window.as_weak();
|
||
let viewport = viewport.clone();
|
||
let redraw = redraw.clone();
|
||
window.on_canvas_resized(move |w_px, h_px| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let size = (w_px.max(1) as u32, h_px.max(1) as u32);
|
||
if *viewport.borrow() == size {
|
||
return;
|
||
}
|
||
*viewport.borrow_mut() = size;
|
||
redraw(&w);
|
||
});
|
||
}
|
||
|
||
// FR-UI-1: layout class from window width. Computed here rather than in
|
||
// Slint because a property that both derives from and feeds the layout is
|
||
// a binding loop.
|
||
let panels = std::rc::Rc::new(PanelChoices::default());
|
||
{
|
||
let weak = window.as_weak();
|
||
let panels = panels.clone();
|
||
window.on_window_resized(move |width| {
|
||
let Some(window) = weak.upgrade() else { return };
|
||
apply_layout_class(&window, width, &panels);
|
||
});
|
||
}
|
||
{
|
||
let size = window.window().size();
|
||
let scale = window.window().scale_factor().max(0.01);
|
||
apply_layout_class(&window, size.width as f32 / scale, &panels);
|
||
}
|
||
|
||
// FR-UI-2: the two collapsible columns, opened and closed by hand.
|
||
{
|
||
let weak = window.as_weak();
|
||
let panels = panels.clone();
|
||
window.on_toggle_panel(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let open = !w.get_panel_visible();
|
||
panels.panel.set(Some(open));
|
||
w.set_panel_visible(open);
|
||
});
|
||
}
|
||
{
|
||
let weak = window.as_weak();
|
||
let panels = panels.clone();
|
||
window.on_toggle_collections(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let open = !w.get_collections_visible();
|
||
panels.collections.set(Some(open));
|
||
w.set_collections_visible(open);
|
||
});
|
||
}
|
||
|
||
// Android's back gesture, and Escape on a keyboard.
|
||
{
|
||
let weak = window.as_weak();
|
||
window.on_back_requested(move || {
|
||
let Some(w) = weak.upgrade() else {
|
||
return false;
|
||
};
|
||
back_one_step(&w)
|
||
});
|
||
}
|
||
|
||
if !entries.borrow().is_empty() {
|
||
show(&window);
|
||
}
|
||
|
||
window.run()?;
|
||
Ok(())
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a
|
||
/// What the originals cache is holding, for the settings page.
|
||
///
|
||
/// Pinned and passive are reported separately because they answer different
|
||
/// questions: the passive figure is what the budget above it governs, while
|
||
/// the pinned figure is disk the user asked for and no ceiling will reclaim.
|
||
/// One combined number would make the budget look wrong whenever a large
|
||
/// collection was pinned.
|
||
fn describe_cache_usage(library: &Rc<library_ui::LibraryController>) -> String {
|
||
let Some(cache) = library.cache() else {
|
||
return String::new();
|
||
};
|
||
let catalog = library.catalog();
|
||
let borrow = catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return String::new();
|
||
};
|
||
let Ok(usage) = cache.usage(catalog.connection()) else {
|
||
return String::new();
|
||
};
|
||
|
||
let gb = |b: u64| b as f64 / 1_073_741_824.0;
|
||
match (usage.passive_count, usage.pinned_count) {
|
||
(0, 0) => "Nothing cached yet".to_string(),
|
||
(_, 0) => format!(
|
||
"{:.1} GB cached ({} images)",
|
||
gb(usage.passive_bytes),
|
||
usage.passive_count
|
||
),
|
||
(0, _) => format!(
|
||
"{:.1} GB pinned ({} images)",
|
||
gb(usage.pinned_bytes),
|
||
usage.pinned_count
|
||
),
|
||
_ => format!(
|
||
"{:.1} GB cached ({} images) · {:.1} GB pinned ({} images)",
|
||
gb(usage.passive_bytes),
|
||
usage.passive_count,
|
||
gb(usage.pinned_bytes),
|
||
usage.pinned_count
|
||
),
|
||
}
|
||
}
|
||
|
||
fn describe_camera(m: &Metadata) -> String {
|
||
match (&m.make, &m.model) {
|
||
(Some(make), Some(model)) => {
|
||
// Model often repeats the make; "Canon Canon EOS 6D" reads badly.
|
||
if model.starts_with(make.as_str()) {
|
||
model.trim().to_string()
|
||
} else {
|
||
format!("{} {}", make.trim(), model.trim())
|
||
}
|
||
}
|
||
(_, Some(model)) => model.trim().to_string(),
|
||
(Some(make), _) => make.trim().to_string(),
|
||
_ => String::new(),
|
||
}
|
||
}
|
||
|
||
fn describe_exposure(m: &Metadata) -> String {
|
||
let mut parts = Vec::new();
|
||
if let Some(s) = m.shutter {
|
||
// Photographers read fractions, not decimals.
|
||
parts.push(if s >= 1.0 {
|
||
format!("{s:.1}s")
|
||
} else {
|
||
format!("1/{}", (1.0 / s).round() as u32)
|
||
});
|
||
}
|
||
if let Some(a) = m.aperture {
|
||
parts.push(format!("f/{a:.1}"));
|
||
}
|
||
if let Some(iso) = m.iso {
|
||
parts.push(format!("ISO {iso}"));
|
||
}
|
||
if let Some(f) = m.focal_length {
|
||
parts.push(format!("{f:.0}mm"));
|
||
}
|
||
parts.join(" ")
|
||
}
|
||
|
||
/// TRACES: FR-UI-1
|
||
/// Which collapsible columns the user has opened or closed by hand.
|
||
///
|
||
/// The layout class supplies each panel's default; this records where the user
|
||
/// disagreed, so a panel closed to see more of a photograph stays closed while
|
||
/// the window keeps its shape.
|
||
///
|
||
/// `class` is what makes that "while": a choice is remembered *within* a layout
|
||
/// class and dropped when the class changes. Rotating a tablet into portrait
|
||
/// asks a different question from the one answered in landscape, and carrying
|
||
/// the landscape answer across is how a user ends up with 232px of sidebar on a
|
||
/// screen that has no room for it and no memory of having asked.
|
||
#[derive(Default)]
|
||
struct PanelChoices {
|
||
class: std::cell::Cell<Option<bool>>,
|
||
panel: std::cell::Cell<Option<bool>>,
|
||
collections: std::cell::Cell<Option<bool>>,
|
||
}
|
||
|
||
fn apply_layout_class(window: &AppWindow, width: f32, panels: &PanelChoices) {
|
||
let expanded = width >= EXPANDED_MIN_WIDTH;
|
||
window.set_expanded(expanded);
|
||
window.set_layout_class(if expanded { "expanded" } else { "compact" }.into());
|
||
|
||
if panels.class.get() != Some(expanded) {
|
||
panels.class.set(Some(expanded));
|
||
panels.panel.set(None);
|
||
panels.collections.set(None);
|
||
}
|
||
|
||
window.set_panel_visible(panels.panel.get().unwrap_or(expanded));
|
||
window.set_collections_visible(panels.collections.get().unwrap_or(expanded));
|
||
}
|
||
|
||
/// TRACES: FR-UI-5
|
||
/// One step back, and whether there was one to take.
|
||
///
|
||
/// The Escape half is FR-UI-5's "keyboard shortcuts cover navigation". The
|
||
/// Android back gesture answers to the same handler and has no numbered
|
||
/// requirement of its own — the register was written before phones and tablets
|
||
/// had a platform section, and §1.3 still lists no navigation requirement.
|
||
///
|
||
/// This is what Android's back gesture and the Escape key both resolve to. The
|
||
/// order is the order the states were entered in, innermost first: a mode
|
||
/// within a view is left before the view is, because that is what the user
|
||
/// most recently did and so what they most likely mean to undo.
|
||
///
|
||
/// Returning `false` means this is the top of the stack. The shell passes that
|
||
/// straight back to the platform as an unhandled key, which on Android closes
|
||
/// the activity — the behaviour every application there has, and the reason
|
||
/// this answers with a bool rather than swallowing the gesture.
|
||
fn back_one_step(w: &AppWindow) -> bool {
|
||
let state = NavState {
|
||
settings: w.get_show_settings(),
|
||
launch: w.get_show_launch(),
|
||
browsing: w.get_launch_browsing(),
|
||
library: w.get_show_library(),
|
||
crop: w.get_crop_mode(),
|
||
zoomed: w.get_zoomed(),
|
||
// Files named on the command line have no grid behind them — the same
|
||
// condition the status strip uses to decide whether to offer the way
|
||
// back at all.
|
||
has_grid: w.get_library_total() > 0,
|
||
scoped: w.get_collection_selected() != 0,
|
||
};
|
||
|
||
let Some(step) = back_step(state) else {
|
||
return false;
|
||
};
|
||
|
||
match step {
|
||
BackStep::CloseSettings => w.invoke_settings_close(),
|
||
BackStep::CancelBrowse => w.invoke_launch_browse_cancel(),
|
||
BackStep::LeaveCrop => w.invoke_crop_mode_toggled(false),
|
||
BackStep::ResetZoom => w.invoke_zoom_reset(),
|
||
BackStep::ToLibrary => w.invoke_back_to_library(),
|
||
BackStep::ClearScope => w.invoke_collection_select(0),
|
||
}
|
||
true
|
||
}
|
||
|
||
/// Where the interface is, as far as going back is concerned.
|
||
///
|
||
/// A flat snapshot rather than the window itself, so the ordering below can be
|
||
/// stated and tested without a Slint backend: which of two states is left first
|
||
/// is the whole of this feature, and it is the part that is easy to get subtly
|
||
/// wrong when it is spelled out in nested `if`s over live properties.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||
struct NavState {
|
||
settings: bool,
|
||
launch: bool,
|
||
browsing: bool,
|
||
library: bool,
|
||
crop: bool,
|
||
zoomed: bool,
|
||
has_grid: bool,
|
||
scoped: bool,
|
||
}
|
||
|
||
/// What one step back does, or `None` at the top of the stack.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum BackStep {
|
||
CloseSettings,
|
||
CancelBrowse,
|
||
LeaveCrop,
|
||
ResetZoom,
|
||
ToLibrary,
|
||
ClearScope,
|
||
}
|
||
|
||
fn back_step(s: NavState) -> Option<BackStep> {
|
||
// Settings is drawn over everything, so it is left first whatever is
|
||
// behind it.
|
||
if s.settings {
|
||
return Some(BackStep::CloseSettings);
|
||
}
|
||
|
||
if s.launch {
|
||
// The folder picker is a step inside the launch screen; the launch
|
||
// screen itself is where the application starts and has nothing behind.
|
||
return s.browsing.then_some(BackStep::CancelBrowse);
|
||
}
|
||
|
||
if !s.library {
|
||
// Develop. Crop is a mode and zoom is a view state; both are left
|
||
// before the image is.
|
||
if s.crop {
|
||
return Some(BackStep::LeaveCrop);
|
||
}
|
||
if s.zoomed {
|
||
return Some(BackStep::ResetZoom);
|
||
}
|
||
return s.has_grid.then_some(BackStep::ToLibrary);
|
||
}
|
||
|
||
// The grid. A collection scoping it is a step in: back widens to the whole
|
||
// library before it considers leaving.
|
||
//
|
||
// And it does not leave: the grid is home, so back from here closes the
|
||
// application as it does in every other Android app. Signing out is a
|
||
// deliberate act reached from "Change library", not somewhere a stray swipe
|
||
// should land.
|
||
s.scoped.then_some(BackStep::ClearScope)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// Develop with a grid behind it — the state most of the back tests vary.
|
||
fn developing() -> NavState {
|
||
NavState {
|
||
has_grid: true,
|
||
..NavState::default()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn back_closes_settings_before_anything_underneath_it() {
|
||
// Settings is reachable from both the grid and develop, and is drawn
|
||
// over whichever it was opened from. Whatever is behind must wait.
|
||
let from_grid = NavState {
|
||
settings: true,
|
||
library: true,
|
||
scoped: true,
|
||
..developing()
|
||
};
|
||
assert_eq!(back_step(from_grid), Some(BackStep::CloseSettings));
|
||
|
||
let from_develop = NavState {
|
||
settings: true,
|
||
crop: true,
|
||
..developing()
|
||
};
|
||
assert_eq!(back_step(from_develop), Some(BackStep::CloseSettings));
|
||
}
|
||
|
||
#[test]
|
||
fn back_leaves_a_mode_before_it_leaves_the_image() {
|
||
// Crop then zoom then the view: innermost first, because that is the
|
||
// order they were entered in.
|
||
let cropping = NavState {
|
||
crop: true,
|
||
zoomed: true,
|
||
..developing()
|
||
};
|
||
assert_eq!(back_step(cropping), Some(BackStep::LeaveCrop));
|
||
|
||
let zoomed = NavState {
|
||
zoomed: true,
|
||
..developing()
|
||
};
|
||
assert_eq!(back_step(zoomed), Some(BackStep::ResetZoom));
|
||
|
||
assert_eq!(back_step(developing()), Some(BackStep::ToLibrary));
|
||
}
|
||
|
||
#[test]
|
||
fn back_from_an_image_with_no_grid_behind_it_is_the_top_of_the_stack() {
|
||
// Files named on the command line: there is no library to return to,
|
||
// and the status strip does not offer one either.
|
||
let standalone = NavState {
|
||
has_grid: false,
|
||
..developing()
|
||
};
|
||
assert_eq!(back_step(standalone), None);
|
||
}
|
||
|
||
#[test]
|
||
fn back_widens_a_scoped_grid_before_it_would_leave_the_grid() {
|
||
let scoped = NavState {
|
||
library: true,
|
||
scoped: true,
|
||
has_grid: true,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(back_step(scoped), Some(BackStep::ClearScope));
|
||
}
|
||
|
||
#[test]
|
||
fn back_from_the_whole_grid_closes_the_application() {
|
||
// The grid is home. Nothing here may navigate to the launch screen:
|
||
// that is where signing out lives, and a stray back swipe must not
|
||
// land on it.
|
||
let home = NavState {
|
||
library: true,
|
||
has_grid: true,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(back_step(home), None);
|
||
}
|
||
|
||
#[test]
|
||
fn back_cancels_the_folder_picker_but_never_leaves_the_launch_screen() {
|
||
let picking = NavState {
|
||
launch: true,
|
||
browsing: true,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(back_step(picking), Some(BackStep::CancelBrowse));
|
||
|
||
let launch = NavState {
|
||
launch: true,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(back_step(launch), None);
|
||
}
|
||
|
||
fn meta() -> Metadata {
|
||
Metadata {
|
||
make: Some("Canon".into()),
|
||
model: Some("Canon EOS 6D".into()),
|
||
shutter: Some(1.0 / 250.0),
|
||
aperture: Some(2.8),
|
||
iso: Some(400),
|
||
focal_length: Some(50.0),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn camera_does_not_repeat_the_make() {
|
||
// rawler reports make "Canon" and model "Canon EOS 6D"; naive
|
||
// concatenation gives "Canon Canon EOS 6D".
|
||
assert_eq!(describe_camera(&meta()), "Canon EOS 6D");
|
||
}
|
||
|
||
#[test]
|
||
fn camera_joins_when_model_omits_the_make() {
|
||
let m = Metadata {
|
||
make: Some("NIKON".into()),
|
||
model: Some("D850".into()),
|
||
..Default::default()
|
||
};
|
||
assert_eq!(describe_camera(&m), "NIKON D850");
|
||
}
|
||
|
||
#[test]
|
||
fn missing_camera_metadata_is_empty_not_a_placeholder() {
|
||
assert_eq!(describe_camera(&Metadata::default()), "");
|
||
}
|
||
|
||
#[test]
|
||
fn shutter_reads_as_a_fraction_below_one_second() {
|
||
assert!(describe_exposure(&meta()).starts_with("1/250"));
|
||
}
|
||
|
||
#[test]
|
||
fn long_exposures_read_as_seconds() {
|
||
let m = Metadata {
|
||
shutter: Some(2.5),
|
||
..Default::default()
|
||
};
|
||
assert_eq!(describe_exposure(&m), "2.5s");
|
||
}
|
||
|
||
#[test]
|
||
fn exposure_omits_absent_fields() {
|
||
let m = Metadata {
|
||
iso: Some(100),
|
||
..Default::default()
|
||
};
|
||
assert_eq!(describe_exposure(&m), "ISO 100");
|
||
assert_eq!(describe_exposure(&Metadata::default()), "");
|
||
}
|
||
|
||
#[test]
|
||
fn only_supported_extensions_are_collected() {
|
||
assert!(is_supported(Path::new("a.CR2")));
|
||
assert!(is_supported(Path::new("a.jpg")));
|
||
assert!(!is_supported(Path::new("a.txt")));
|
||
assert!(!is_supported(Path::new("noextension")));
|
||
}
|
||
|
||
fn points(values: &[f32]) -> slint::ModelRc<f32> {
|
||
slint::ModelRc::new(slint::VecModel::from(values.to_vec()))
|
||
}
|
||
|
||
#[test]
|
||
fn an_unmoved_curve_reports_no_change() {
|
||
// What spares every non-curve drag the 96-sample spline evaluation.
|
||
let existing = points(&[0.0, 0.0, 1.0, 1.0]);
|
||
let fresh = points(&[0.0, 0.0, 1.0, 1.0]);
|
||
assert_eq!(
|
||
update_points_in_place(&existing, &fresh),
|
||
PointsUpdate::Unchanged
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_moved_point_reports_the_change_and_is_written_through() {
|
||
use slint::Model as _;
|
||
|
||
let existing = points(&[0.0, 0.0, 1.0, 1.0]);
|
||
let fresh = points(&[0.0, 0.25, 1.0, 1.0]);
|
||
assert_eq!(
|
||
update_points_in_place(&existing, &fresh),
|
||
PointsUpdate::Moved
|
||
);
|
||
// Written into the *existing* model: keeping its identity is what
|
||
// stops the drag's own TouchArea being destroyed mid-gesture.
|
||
assert_eq!(existing.row_data(1), Some(0.25));
|
||
}
|
||
|
||
#[test]
|
||
fn a_different_point_count_is_incompatible() {
|
||
// A different image, so there is no drag to preserve and the caller
|
||
// must take the fresh model wholesale.
|
||
let existing = points(&[0.0, 0.0]);
|
||
let fresh = points(&[0.0, 0.0, 1.0, 1.0]);
|
||
assert_eq!(
|
||
update_points_in_place(&existing, &fresh),
|
||
PointsUpdate::Incompatible
|
||
);
|
||
}
|
||
}
|