//! 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; /// 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, fallback: Option, 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 { // 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 { 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::::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 { 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 = 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>>) { 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>, session: &Rc>>, ) { 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, fresh: &slint::ModelRc, ) -> 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) -> 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>>> = 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>> = 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> = 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 = { 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::::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::::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) -> 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>, panel: std::cell::Cell>, collections: std::cell::Cell>, } 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 { // 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 { 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 ); } }