The grid now zooms, which needs thumbnails at two resolutions rather than one, and exposed a scrub that landed in the wrong place. **Two thumbnail size classes.** `ThumbSize::Grid` (256px, ~10 KB) and `Large` (1024px, ~45 KB), with the class part of the store key so both coexist. Storing everything large would take the reference library from ~200 MB to ~860 MB, and shards sync, so that is transfer cost on every device rather than only disk. A store written before the class existed migrates in place: its entries are all grid-sized, which is what the column defaults to, so nothing already fetched is discarded. `forget` now drops every size for an image. Reading a single row left the other class's bytes on the shard's tally for good, sealing it early on space nothing occupied. **Grid zoom.** Ctrl+wheel and pinch resize cells between 90px and 420px in geometric steps, so the gesture feels the same at either end where a fixed pixel step would be imperceptible at 400px and violent at 90px. Crossing 256px switches to the large class, so a zoomed cell is sharp rather than upscaled. Columns and window capacity already derived from cell size, so the grid reflows for free. **The scrub landed about half a library too high.** It counted only dated images while the grid shows all of them — 10,733 dated against 19,841 rows — and ignored `shadowed_by`. Verified against the live catalog: the old formula gave 10,887, the new one 10,732, the true grid position 10,732. The scrub's count and the grid's window must use identical predicates and ordering; a test now fails if they diverge. **Timeline gestures are continuous.** Scrub and pan were quantised to whole buckets, so a slow drag did nothing until it crossed a boundary and then jumped a month. Both work in fractions of the visible span now, and pinch-to-zoom arrives for tablet, where there is no wheel to reach the axis with. The pinch accumulator was wrong on first writing: it took at most one step per update, so an 8x spread — three doublings — yielded one zoom level. `log2().trunc()` now extracts every whole doubling and carries the remainder. The original test asserted the wrong number and defended it in a comment, which is worth remembering: a test can entrench a bug as readily as catch one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1245 lines
48 KiB
Rust
1245 lines
48 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 collections_ui;
|
||
mod derived_sync;
|
||
mod develop;
|
||
mod labels;
|
||
mod library;
|
||
mod library_ui;
|
||
mod trash;
|
||
#[cfg(live_style)]
|
||
mod live_style;
|
||
|
||
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);
|
||
|
||
/// 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);
|
||
|
||
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)
|
||
})
|
||
} 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))
|
||
};
|
||
|
||
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);
|
||
|
||
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);
|
||
}
|
||
|
||
/// 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 _;
|
||
|
||
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()?;
|
||
|
||
// 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();
|
||
|
||
// 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();
|
||
|
||
// 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,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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: Rc<dyn Fn(&AppWindow, bool)> = {
|
||
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());
|
||
}
|
||
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();
|
||
*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;
|
||
};
|
||
|
||
log::info!("fetching {path} for develop");
|
||
w.set_load_error("Downloading…".into());
|
||
|
||
let rx = library::spawn_full_fetch(creds, user_id, path.clone());
|
||
|
||
// 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) => {
|
||
log::warn!("{name}: {e}");
|
||
w.set_load_error(e.into());
|
||
return;
|
||
}
|
||
};
|
||
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);
|
||
}
|
||
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 weak = window.as_weak();
|
||
window.on_window_resized(move |width| {
|
||
let Some(window) = weak.upgrade() else { return };
|
||
apply_layout_class(&window, width);
|
||
});
|
||
}
|
||
{
|
||
let size = window.window().size();
|
||
let scale = window.window().scale_factor().max(0.01);
|
||
apply_layout_class(&window, size.width as f32 / scale);
|
||
}
|
||
|
||
if !entries.borrow().is_empty() {
|
||
show(&window);
|
||
}
|
||
|
||
window.run()?;
|
||
Ok(())
|
||
}
|
||
|
||
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(" ")
|
||
}
|
||
|
||
fn apply_layout_class(window: &AppWindow, width: f32) {
|
||
let expanded = width >= EXPANDED_MIN_WIDTH;
|
||
window.set_expanded(expanded);
|
||
window.set_layout_class(if expanded { "expanded" } else { "compact" }.into());
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
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
|
||
);
|
||
}
|
||
}
|