//! Slint interface for DarkRoom. //! //! A viewer with a develop panel: open a folder of RAW files, decode and //! demosaic on the GPU, and adjust. //! //! **The develop frame never leaves the GPU** (ARCH §6.1, AC-8). Spike S1 //! wired Slint's texture import: [`shared_gpu`] opens one wgpu device and //! gives it to *both* the compute passes and Slint's renderer, and //! `DevelopSession::render` then hands the compositor the very texture the //! adjust pass wrote. What used to be a readback and an upload per frame is //! now a refcount. //! //! `SharedPixelBuffer` still appears in this file and in the library grid, and //! that is not a relapse: an embedded JPEG preview and a thumbnail are decoded //! on the CPU and have no texture to hand over. AC-8 is about pixels that were //! *computed on the GPU* travelling to the CPU and back to be looked at. //! //! **The develop panel is generated, not written.** [`develop`] asks the //! pipeline what parameters it has and builds a control per answer; no code //! in `ui/` names an operation or knows a shader exists (FR-DEV-3a). mod activity; mod bursts; mod collections_ui; mod derived_sync; mod develop; mod display_ui; mod export; pub mod faces; pub use library::render_native; // Generated from the `GESTURE:` comments beside the code that implements each // one — see `tools/traceability`. Regenerate with // `cargo run -p traceability -- gestures`; CI fails if it has drifted. mod gesture_book; mod gestures; mod gradient; mod histogram; pub mod identity; mod identity_ui; mod import; mod import_ui; mod labels; mod library; mod library_ui; #[cfg(live_style)] mod live_style; mod masks_ui; pub mod memory; mod net_runtime; mod peaking; mod place; mod preset_store; mod presets; mod recovery_ui; mod remote; mod segmentation; mod settings_store; mod settings_ui; mod sidecar_cache; mod spots_ui; mod trash; use std::cell::{Cell, RefCell}; use std::path::{Path, PathBuf}; use std::rc::Rc; use anyhow::Result; use dr_decode::{Metadata, PreviewSize}; pub use develop::DevelopSession; /// Where a model has to land for face indexing to find it, on any account. /// /// Public for the Android entry point, which is the only caller that knows the /// APK may carry a bundled copy and has to write it out before any store is /// opened — see `library::shared_face_models_dir`. pub use library::shared_face_models_dir; /// The scene model's three files, wherever this device keeps them. /// /// Public for the same reason as the directory above: the pieces that reach for /// a model are not all inside this crate. Exported ahead of the scene tab that /// will consume it so that the packaging and unpacking added alongside it have /// something to be verified against — `assemble-apk.sh` writing files no lookup /// looks for would be a silent mistake for as long as the tab took to arrive. pub use library::scene_model; pub mod launch; pub mod launch_ui; slint::include_modules!(); /// TRACES: FR-DSP-1 | NFR-RES-1 /// Longest edge the viewer renders at. /// /// FR-DSP-1: work at the resolution the viewport needs, not the source /// resolution. A 5472×3648 preview is 79.8 MB of RGBA; at 2048 it is 11 MB, /// which is what keeps a folder browsable within NFR-RES-1's budget. const MAX_DISPLAY_DIM: u32 = 2048; /// TRACES: FR-UI-1 | FR-UI-2 | M-16 /// Width at which the expanded layout appears (FR-UI-1). /// /// Logical pixels, not a device check — a narrow desktop window gets the /// compact layout exactly as a tablet in portrait would. const EXPANDED_MIN_WIDTH: f32 = 820.0; /// TRACES: FR-UI-1 /// The largest share of the window the develop column may take. /// /// A policy, not a size. The column is a fixed `panel-width` (`style.yaml`), /// so on any window with room for it this does nothing at all — it bites only /// where 360px would be most of the screen, and what it says there is that the /// majority of the window stays with the photograph the column exists to /// serve. /// /// It used to do more, because the column used to size itself to the widest /// thing it held and could therefore grow without limit on a rich operation /// set. It cannot any more; this is the remaining half of that guard, kept for /// the case the fixed width does not cover. const PANEL_MAX_FRACTION: f32 = 0.45; /// The floor under that share, so a narrow window still gets a usable column /// rather than one squeezed below the width its own controls were drawn for. /// Under `panel-width` by design: between the two the column narrows from 360 /// to 280 on a small window and stops, which is the range the sliders inside /// it stay accurate over. const PANEL_MIN_WIDTH: f32 = 280.0; /// 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(); // 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 { match open_session(ctx, bytes, orientation) { 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, }) } /// TRACES: FR-EXP-7 | FR-RAW-4 /// Open bytes for editing, with no interface types involved. /// /// Split out of [`load_bytes`] so the batch exporter can share it. That runs on /// a worker thread, where a `slint::Image` has no business being constructed — /// and a second copy of the JPEG-versus-RAW routing is a second copy that would /// drift, which is exactly how a batch comes to export something the viewer /// would have shown differently. pub(crate) fn open_session( ctx: &dr_gpu::GpuContext, bytes: &[u8], orientation: dr_types::Orientation, ) -> Result { // Route by what the bytes actually are, not by extension (M-9). A JPEG has // no sensor data and never will, so trying the RAW decoder first would be a // guaranteed failure whose log line reads like a fault. if dr_decode::probe(bytes) == Some(dr_types::Format::Jpeg) { return dr_decode::decode_jpeg(bytes) .map_err(|e| e.to_string()) .and_then(|mut p| { // Fit the device before uploading. A film scan runs to // 13728×8928, well past the 8192 a typical GPU can hold, and // refusing it would drop the image back to a read-only preview // — the very thing this path exists to avoid. 8192 is still // four times a 4K long edge. let limit = dr_gpu::DemosaicedImage::max_dimension(ctx); if p.width.max(p.height) > limit { log::info!( "{}×{} exceeds the {limit} texture limit; fitting to it", p.width, p.height ); p.downscale_to(limit); } DevelopSession::open_rgb(ctx, &p.rgba, p.width, p.height, orientation) }); } // A failure here is expected for bodies rawler does not know, and must not // stop the image displaying (FR-RAW-4). dr_decode::decode(bytes) .map_err(|e| e.to_string()) .and_then(|raw| DevelopSession::open(ctx, &raw, orientation)) } /// Collect displayable images from file or directory arguments. fn collect(paths: &[PathBuf]) -> Vec { 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 the view mode are properties of *looking at one photograph*, so /// carrying them to the next one would leave the second image cropped to a /// rect chosen for the first — or, since local masking became a mode, would /// open the next photograph with a mask stack it does not have. fn reset_view_state(window: &AppWindow) { window.set_view_mode(ViewMode::Photo); window.set_zoom(1.0); window.set_zoomed(false); window.set_crop_x(0.0); window.set_crop_y(0.0); window.set_crop_w(1.0); window.set_crop_h(1.0); 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); // A photograph that failed to decode has no session, so nothing below // will speak for it — and the buttons would otherwise keep offering the // previous image's history. window.set_can_undo(false); window.set_can_redo(false); // TRACES: FR-DSP-7 // Emptied rather than left standing: the previous photograph's histogram // beside the next one's filename is a confident, precise lie, and the gap // before the new frame settles is exactly long enough to read it. window.set_histogram(histogram::empty()); // And the raw reading with it. It belongs to one file more completely than // the display histogram does — nothing about the edit can move it — which // makes leaving it standing over the next photograph's filename worse // rather than better: it would look exactly as current as it is wrong. window.set_raw_histogram(histogram::raw_empty(histogram::RawAbsence::NoImage)); // TRACES: FR-CULL-3 // The marks go down with it, and for the same reason. What is *not* reset // is whether peaking is switched on: that is a way of looking at a folder // rather than a property of one photograph, so it survives to the next // frame — see `chosen_peaking` for the whole of that argument. window.set_focus_overlay_ready(false); // TRACES: FR-DEV-3 // The region map belongs to one photograph. Carrying the stack, the // overlay or the crosshair to the next one would offer a selection of // regions that are not in the picture on screen. masks_ui::reset(window); // TRACES: FR-DEV-8 // The repairs belong to one photograph too. The next one's arrive with its // sidecar a moment later, and until they do the canvas must not be showing // the last one's. spots_ui::reset(window); } /// 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); } /// TRACES: FR-DEV-3 /// Push the chosen crop ratio back to the panel. /// /// The index is written from the choice actually held rather than from what /// was clicked, for the same reason [`sync_framing`] writes the applied crop: /// picking a ratio with no portrait form clears the orientation switch, and a /// panel showing the click rather than the result would light a switch that is /// doing nothing. fn sync_crop_aspect( window: &AppWindow, aspect: &Rc>, portrait: &Rc>, ) { let chosen = aspect.get(); let index = develop::CropAspect::CHOICES .iter() .position(|a| *a == chosen) .unwrap_or(0); window.set_crop_aspect(index as i32); window.set_crop_portrait(portrait.get()); window.set_crop_aspect_turnable(chosen.has_orientation()); } /// TRACES: FR-DEV-3 /// Reshape the open image's crop onto the chosen ratio, about its centre. /// /// Called when the ratio itself changes, never on opening a photograph: the /// lock outlives the session it is applied to, and reshaping a crop the user /// has not touched — on an image they have only just opened — would edit their /// work as a side effect of stepping through a shoot. A ratio takes effect /// when it is chosen and when a handle is dragged, both of which are things /// the user did on purpose. fn apply_crop_aspect( window: &AppWindow, session: &Rc>>, aspect: &Rc>, portrait: &Rc>, ) { let Some(s) = session.borrow_mut().as_mut().map(|s| { s.set_crop_locked(s.crop(), aspect.get(), portrait.get(), (0.5, 0.5)); (s.crop(), s.framing_edits_image()) }) else { return; }; let (crop, modified) = s; window.set_crop_x(crop.x); window.set_crop_y(crop.y); window.set_crop_w(crop.width); window.set_crop_h(crop.height); window.set_framing_modified(modified); } /// TRACES: FR-EXP-6 | FR-EXP-9 | FR-EXP-7 /// Render the open image at full resolution, ready to be handed to the worker. /// /// The render stays here, on the UI thread, and everything after it does not. /// That split is deliberate rather than the remains of the synchronous version /// this replaced: the frame belongs to the `DevelopSession` the interface owns, /// and the edit in it may not have reached a sidecar yet, so a worker that /// re-opened the photograph for itself would export the *saved* version rather /// than the one on screen. /// /// What is left on this thread is a GPU pass and a readback. The Lanczos /// reduction, the encode and the write — the larger half of the wait, and all /// of its variance — leave with the frame. fn render_open_frame( window: &AppWindow, session: &Rc>>, space: dr_types::ColourSpace, ) -> Result { let mut borrowed = session.borrow_mut(); let Some(session) = borrowed.as_mut() else { return Err("nothing is open".into()); }; // The colour space is chosen at render time because the conversion happens // in the shader, before the clip to 0..1 — see `render_for_export`. let frame = session.render_for_export(space)?; let filename = window.get_filename().to_string(); let stem = std::path::Path::new(&filename) .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "export".into()); Ok(export::Source::Rendered { stem, frame }) } /// TRACES: FR-EXP-7 /// Everything a batch needs that only the UI thread can assemble. fn batch_request( sources: Vec, settings: &Rc, library: &Rc, gpu: Option<&dr_gpu::GpuContext>, ) -> export::BatchRequest { let stored = settings.snapshot(); export::BatchRequest { sources, conn: library.credentials(), settings: stored.export, outbox: match library.session() { Some(c) => export::outbox_dir(&c.account), // No account, so no outbox — a device export still works, and a // remote one is refused by `place` rather than here, so the message // names the setting rather than the plumbing. None => std::env::temp_dir().join("darkroom-outbox"), }, sidecar_cache: library.sidecar_cache_dir().unwrap_or_default(), offline: library.is_offline(), gpu: gpu.cloned(), } } /// TRACES: FR-EXP-7 | FR-NC-10 /// Send anything waiting in the outbox to the server. /// /// Called after an export and again on every sync pass. Both, deliberately: /// the first is what makes an upload feel immediate, and the second is what /// eventually delivers the exports made while the train was in a tunnel. /// Running it twice over an empty outbox costs a directory listing. fn drain_outbox(library: &Rc) { // Offline is not a failure worth reporting here — the entries stay // staged and the next pass takes them. if library.is_offline() { return; } let Some(conn) = library.session() else { return; }; let outbox = export::outbox_dir(&conn.account); if export::pending_count(&outbox) == 0 { return; } let root = conn.account.root.clone(); let rx = export::spawn_upload(conn, root, outbox); std::thread::spawn(move || { while let Ok(msg) = rx.recv() { match msg { export::UploadMessage::Status(s) => log::info!("export: {s}"), export::UploadMessage::Finished { uploaded, remaining, error, } => { log::info!("export: {uploaded} uploaded, {remaining} still queued"); if let Some(e) = error { log::warn!("export upload stopped: {e}"); } } } } }); } /// What the export button should say, given where an export would go. /// /// The label carries the destination because the button is the only place the /// distinction is visible from: "Export" alone gives no hint whether the file /// lands on this device or is queued for a server that may be unreachable. fn refresh_export_label(window: &AppWindow, settings: &Rc) { let stored = settings.snapshot(); let remote = stored.export.target == dr_types::ExportTarget::Remote; window.set_export_label( if remote { // Not the connector's name: this is a Nextcloud account for some // libraries and a folder on a mount for others. "Export to the library" } else { "Export" } .into(), ); // The grid's button says the same thing about a selection, but it composes // its own label around a count — so it is given the fact rather than the // sentence (FR-EXP-7). window.set_export_to_server(remote); } /// Push current parameter values back to the interface. /// /// The controls are not self-updating: the core clamps values, so what the /// user dragged to and what the parameter became can differ, and the control /// must show the latter. /// **Updates rows in place; never replaces the model.** Assigning a fresh /// `ModelRc` tears down and rebuilds every row element — including the /// `TouchArea` currently tracking the pointer — which cancels the drag in /// progress. The symptom is a slider that jumps on click but cannot be /// dragged, because each move event destroys the thing that would deliver /// the next one. /// TRACES: FR-CAT-8 /// Apply a fetched sidecar to the open session, now or as soon as it arrives. /// /// The sidecar fetch is started beside the image fetch and is three orders of /// magnitude smaller, so it has almost always landed by the time there is a /// session to apply it to — and this takes it straight from the channel. The /// timer covers the case where it has not, which is why this is not simply a /// blocking receive: a slow or stalled sidecar request must not freeze the /// window with the photograph already decoded and on screen. /// /// A late arrival redraws, so the image is correct either way; the only /// difference is whether it was ever briefly shown at its defaults. fn apply_when_ready( window: &AppWindow, rx: Rc>>, session: &Rc>>, rows: &Rc>, redraw: &Rc, ) { // Already here — the overwhelmingly common case. if let Ok(got) = rx.try_recv() { if let Some(sidecar) = got { presets::apply_stored_edit(window, &sidecar, session, rows); } return; } let weak = window.as_weak(); let session = session.clone(); let rows = rows.clone(); let redraw = redraw.clone(); let timer = Rc::new(slint::Timer::default()); let held = timer.clone(); timer.start( slint::TimerMode::Repeated, std::time::Duration::from_millis(50), move || { let Ok(got) = rx.try_recv() else { return }; held.stop(); let Some(w) = weak.upgrade() else { return }; let Some(sidecar) = got else { return }; if presets::apply_stored_edit(&w, &sidecar, &session, &rows) { redraw(&w); } }, ); } /// TRACES: FR-DEV-3f /// Push the film choice out to the panel. /// /// Separate from [`sync_rows`] because the stock is not a row: it is not a /// parameter, so it is not in the model the panel generates its controls from. /// Called from the two handlers that change it, and once when an image opens. pub(crate) fn sync_film(window: &AppWindow, session: &Rc>>) { let choices = DevelopSession::film_choices(); let chosen = session .borrow() .as_ref() .and_then(|s| s.film().map(|(stock, print)| (stock.to_string(), print))); // A stock this build has no profile for leaves the picker on "None" rather // than inventing an entry for it. The sidecar still carries the name — it // is only the *control* that cannot show what it does not have. let selected = chosen .as_ref() .and_then(|(stock, _)| { choices .iter() .position(|(id, _)| *id == Some(stock.as_str())) }) .unwrap_or(0); let can_print = choices .get(selected) .and_then(|(id, _)| *id) .and_then(dr_film::find) .and_then(dr_film::default_print) .is_some(); window.set_film_stocks(slint::ModelRc::new(slint::VecModel::from( choices .into_iter() .map(|(_, name)| slint::SharedString::from(name)) .collect::>(), ))); window.set_film_selected(selected as i32); window.set_film_can_print(can_print); window.set_film_print(chosen.map(|(_, print)| print).unwrap_or(false)); } pub(crate) 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); // TRACES: FR-DEV-3f // The film, for the same reason and by the same argument: it is not a row // — a stock is not a parameter — but it is panel state that has to stay in // step, and putting it here means every path that refreshes the controls // refreshes the picker. Doing it at the *call sites* is what left the // control invisible until something else happened to sync it: the list is // populated here, and a panel opened without this has no stocks to offer. sync_film(window, session); // TRACES: FR-DEV-3a // The groups, derived from what the operations say they are about. This // side resolves the labels; nothing here or in `adjust.slint` names an // operation or a group. let (tabs, active_tab) = match session.borrow().as_ref() { Some(s) => ( s.tabs().into_iter().map(|(_, name)| name.into()).collect(), s.active_tab(), ), None => (Vec::::new(), -1), }; window.set_adjust_tabs(slint::ModelRc::new(slint::VecModel::from(tabs))); window.set_adjust_active_tab(active_tab); let current = match session.borrow().as_ref() { Some(s) => s.rows(), None => Vec::new(), }; // Whether the drawn curve has to be resampled. Sampling runs the spline 96 // times and builds a fresh model, and `sync_rows` is called on *every* // parameter event — so doing it unconditionally spent that on every // exposure or contrast drag, none of which can change the curve's shape. // Only a moved point can, and the in-place update below is what knows. let mut curve_moved = false; if current.len() == rows.row_count() { for (i, mut row) in current.into_iter().enumerate() { let existing = rows.row_data(i); // A curve row carries a *nested* model of point coordinates, and // `rows()` builds a fresh one each call. Swapping it in would // destroy the point elements — including the `TouchArea` holding // the current drag — so the existing model is kept and its values // written through instead. // // It also makes the equality test below meaningful: `ModelRc` // compares by identity, so a brand-new points model would make // every curve row look changed on every event. if let Some(previous) = existing.as_ref() { match update_points_in_place(&previous.points, &row.points) { PointsUpdate::Moved => { curve_moved = true; row.points = previous.points.clone(); } PointsUpdate::Unchanged => row.points = previous.points.clone(), PointsUpdate::Incompatible => {} } } // Only touch rows that actually changed, so unrelated controls // are not needlessly invalidated. if existing.as_ref() != Some(&row) { rows.set_row_data(i, row); } } } else { // A different image, so the control set itself changed. Rebuilding // is correct here — there is no drag to preserve, and the new image's // curve must be drawn whatever shape it is in. rows.set_vec(current); curve_moved = true; // The curves the widget can switch between, named. They can only // change with the operation set, which is what this branch means, so // the walk that derives them is not on the parameter-event path. let channels: Vec = match session.borrow().as_ref() { Some(s) => s.curve_channels().into_iter().map(Into::into).collect(), None => Vec::new(), }; window.set_curve_channels(slint::ModelRc::new(slint::VecModel::from(channels))); } // Which curve is plotted, on every pass. Picking one that happens to be // shaped like the last — two untouched curves are both the diagonal — // moves no point, so this cannot ride on the resample below: the chips // would go on highlighting the curve the user just navigated away from. let channel = session.borrow().as_ref().map_or(0, |s| s.curve_channel()); window.set_curve_channel(channel); if !curve_moved { return; } let samples = match session.borrow().as_ref() { Some(s) => s.curve_samples(), None => Vec::new(), }; // The drawn curve follows the points. Replacing this model wholesale is // safe where replacing `rows` was not: nothing in it is a drag target. window.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: FR-DSP-1 | AC-8 /// Open the one wgpu device the compute passes and the compositor share. /// /// **This is the whole of the zero-copy display path, and it is four lines of /// configuration.** A `wgpu::Texture` belongs to the device that allocated it; /// handing one to a compositor drawing on a *different* device is meaningless, /// and the two would have to meet through system memory — which is the round /// trip ARCH §6.1 forbids. So there is exactly one device, made here, before /// anything else needs it. /// /// **Called before the window exists, and it must be.** `BackendSelector` /// installs the Slint platform, and Slint installs a default one the first /// time a window is created; selecting afterwards is too late. That is why the /// GPU is opened at the top of [`run`] rather than beside the other /// controllers, where it used to sit. /// /// `None` means develop is unavailable and the viewer falls back to embedded /// previews — the same degradation as a machine with no adapter at all. #[cfg(not(target_os = "android"))] fn shared_gpu() -> Option { let shared = match pollster::block_on(dr_gpu::GpuContext::new_shared()) { Ok(shared) => shared, Err(e) => { log::warn!("no shareable GPU: {e}"); return None; } }; let dr_gpu::SharedGpu { ctx, instance, adapter, } = shared; // `Manual` is the variant that means "render with these, do not open your // own". The two clones are of wgpu handles, which are refcounts over the // one device and the one queue — not copies of either. let configuration = slint::wgpu_29::WGPUConfiguration::Manual { instance, adapter, device: (*ctx.device).clone(), queue: (*ctx.queue).clone(), }; if let Err(e) = slint::BackendSelector::new() .require_wgpu_29(configuration) .select() { // Dropping the context rather than keeping it: Slint has fallen back // to a renderer that did not adopt our device, so every texture this // context produces is one the compositor cannot sample. A disabled // develop panel is a visible, explicable failure; a texture handed // across devices is undefined behaviour on a good day. log::warn!("Slint would not adopt the GPU device, develop disabled: {e}"); return None; } Some(ctx) } /// TRACES: FR-DSP-1 | AC-8 /// Open the compute device on Android — and do **not** give it to Slint. /// /// # Why this platform is different /// /// The desktop version above exists so one `wgpu::Texture` can be written by /// the adjust pass and sampled by the compositor without a round-trip. That /// requires Slint to be drawing with wgpu, and on Android drawing with wgpu /// means drawing on wgpu's Vulkan swapchain — which hardcodes /// `preTransform = IDENTITY` (gfx-rs/wgpu#3345). /// /// On a tablet whose panel is mounted landscape, portrait then presents an /// unrotated buffer, every present returns `VK_SUBOPTIMAL_KHR`, and the frames /// arrive torn. Measured on the device: portrait sits on `composition=CLIENT` /// with `bufferTransform=ROT_270`, landscape on `composition=DEVICE` with /// `ROT_180`, and only portrait tore. Taking Slint off wgpu — it then uses /// Skia over OpenGL, where the driver owns the rotation — fixed it. /// /// # What it costs, and why that is the right trade here /// /// Without a shared device the display path needs a readback: /// `AdjustPass::export_pixels` instead of `Image::try_from(texture)`. That is /// the transfer ARCH §6.1 and AC-8 exist to avoid, and it is still the better /// bargain on this platform — the alternative is not a faster develop view, it /// is a torn one, in the orientation a tablet is mostly held in. /// /// The compute passes are untouched: demosaic and adjust still run on the GPU, /// on this device. Only the last hop to the screen goes through memory. #[cfg(target_os = "android")] fn shared_gpu() -> Option { match pollster::block_on(dr_gpu::GpuContext::new_headless()) { Ok(ctx) => Some(ctx), Err(e) => { log::warn!("no GPU for the develop passes: {e}"); None } } } /// 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()); // TRACES: FR-PLAT-LIN-1 // The name the compositor knows this window by, and the reason the // launcher shows a real icon rather than a grey square. // // `app.slint` sets `icon:`, and that is genuinely embedded (see // `build.rs`) — but a Wayland compositor ignores a client-set icon // entirely. It takes the icon from the `.desktop` file whose basename // matches the surface's `app_id`, and nothing else. So the embedded icon // is what X11 and the window itself use, and *this* is what GNOME's // overview, dash and alt-tab use. Both are needed and neither substitutes // for the other. // // The string must equal the installed `.desktop` file's basename exactly: // `packaging/paris.tourolle.darkroom.desktop`. It matches the Android // package id (`AndroidManifest.xml`) on purpose — one application, one // reverse-DNS name on both platforms. // // Before the window, necessarily: it is read when the surface is created. if let Err(e) = slint::set_xdg_app_id("paris.tourolle.darkroom") { // Not fatal. On X11 and on Android this does nothing useful, and a // missing app id costs an icon rather than a working application. log::debug!("could not set the xdg app id: {e}"); } // Everything between here and `window.run()` at the bottom happens before // the event loop exists, and on Android that is not merely a slow start: // `android_main` calls this, and nothing drains the activity's input // channel until Slint reaches its first `poll_events` inside `run()`. Five // seconds of it is an ANR, over a window that has never painted. // // So the two phases that touch a driver or a disk are timed, and so is the // whole of it. There is no profiler on the tablet and no way to attach one // to a launch: a line in the log file is the only measurement anybody gets, // and without it the next regression here is guesswork about which of half // a dozen candidates it was. let launch_began = std::time::Instant::now(); // Before the window, and it has to be: this selects the Slint backend, and // creating a window selects one for us. See `shared_gpu`. The device is // shared by demosaic, the adjust pass and the compositor; without one the // app still browses through the preview path, just without develop. // // Timed rather than moved: this is a Vulkan instance, an adapter // enumeration and a device request, none of which can be deferred on the // desktop because the backend must be chosen before a window exists. On // Android it could be — nothing shares that device with the compositor // (TD-1) — but "could be" is not "costs enough to be worth it", and this // line is what will say which. let gpu = shared_gpu(); log::info!("gpu opened in {} ms", launch_began.elapsed().as_millis()); let before_window = std::time::Instant::now(); let window = AppWindow::new()?; log::info!("window built in {} ms", before_window.elapsed().as_millis()); // The version the About page shows. Taken from the crate rather than // passed in, so it is the version of the code that is running and cannot // be set to something else by a caller. window.set_app_version(env!("CARGO_PKG_VERSION").into()); match &gpu { Some(ctx) => { log::info!("adapter: {} ({:?})", ctx.adapter_name(), ctx.backend()); window.set_adapter(ctx.adapter_name().into()); window.set_backend(format!("{:?}", ctx.backend()).to_uppercase().into()); } None => window.set_backend("NO GPU".into()), } // 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)); // TRACES: FR-CAT-8 // The same knot, for the other direction: leaving develop has to persist // the edit, and the grid's "‹ Library" button is wired before the develop // session exists to save from. Empty until then, and calling it is a no-op // rather than a panic — there is nothing open to lose. #[allow(clippy::type_complexity)] let leave_develop: Rc>>> = 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()); // Hoisted out of the launch block below because the settings clipboard // needs it too: a paste onto "the selection" reads the selection from // here, and that wiring happens once the develop session exists. let collections = collections_ui::CollectionsController::new(activity.clone()); // The People screen's own state: which person is open, and which of their // faces are ticked for a split. let identity = std::rc::Rc::new(identity_ui::IdentityController::new()); // Settings: cache ceilings, export defaults and the face grouping dials, in // their own config file. // // Wired independently of every view below. It reads no library and holds no // session, so it has nothing to be sequenced against — which is the reason // it is a page reachable from anywhere rather than a panel inside one view. // Hoisted this far up because three separate things need the same record: // the export action, the importer, and the People screen's grouping dials. // A controller scoped to any one wiring block would be gone by the time the // others were built, and two controllers each holding their own copy would // each save over the other. let settings = settings_ui::SettingsController::new(); // TRACES: FR-PLAT-AND-5 // The thumbnail tier. Registered here, beside the thing it frees, so that // a controller which grows another cache is one line from offering it up. // // Weak, not strong: `run` returns when the window closes, and a registry // holding the last reference to a controller would keep it — and every // decoded portrait in it — alive past the interface it belonged to. { let identity = std::rc::Rc::downgrade(&identity); memory::evict_at(memory::Tier::Thumbnails, move || { if let Some(ctl) = identity.upgrade() { ctl.clear_covers(); } }); } // Launch screen: shown when there is nothing to display — no local paths // and no configured library. A user who has already signed in and chosen // a folder goes straight to their images (FR-NC-1). { let controller = launch_ui::LaunchController::new(); let startup = controller.model.borrow().startup_action(!paths.is_empty()); window.set_show_launch(startup == launch::Startup::ShowLaunchScreen); let library = library.clone(); let collections = collections.clone(); // The click handler needs `show`, which is built further down because // it captures the develop session and the GPU context. This cell is // the knot between them: wired empty here, filled once `show` exists. // A click before then is a no-op rather than a panic — the grid cannot // be reached until the window is running, by which point it is set. let open_from_library = open_from_library.clone(); let leave_develop = leave_develop.clone(); library_ui::wire( &window, library.clone(), collections.clone(), move |path| { let Some(f) = open_from_library.borrow().clone() else { log::warn!("open requested before the viewer was ready: {path}"); return; }; f(path); }, Rc::new(move || { if let Some(f) = leave_develop.borrow().clone() { f(); } }), ); // The collections sidebar shares the library's catalog handle rather // than opening its own: one SQLite connection, so an edit here is // visible to the grid's next read without a reopen. // // The reload closure is the seam between the two controllers. The // sidebar decides *what* is scoped; the library owns the window, the // offset and the thumbnail workers, so it is what actually reloads — // and it must be told the scope before it reads, which is why both // happen here in one place rather than each controller reaching for the // other. { let weak = window.as_weak(); let lib = library.clone(); let coll = collections.clone(); let lib_ids = library.clone(); let lib_span = library.clone(); let lib_session = library.clone(); collections_ui::wire( &window, collections.clone(), library.catalog(), move || { let Some(w) = weak.upgrade() else { return }; // Order matters: `set_scope` clears the trash flag, because // picking a collection is how you leave the trash. Setting // the flag second is what lets selecting the trash itself // survive the call. lib.set_scope(coll.scope()); lib.set_viewing_trash(coll.viewing_trash()); library_ui::reload(&w, &lib); }, move || lib_ids.visible_ids(), // What a shift-click selects: the whole run between its two // ends, read from the catalog rather than from the hundred or // so rows that happen to be loaded. move |first, last| lib_span.ids_in_span(first, last), // The trash's MOVE and DELETE go to the same account the scan // and thumbnail workers use. move || lib_session.session(), ); // The People screen reads the same catalog and the same thumbnail // store the grid does — its face crops come from the proxies the // grid already built, which is the whole reason face indexing is // affordable (FR-CULL-8). let lib_store = library.clone(); identity_ui::wire( &window, identity.clone(), library.catalog(), activity.clone(), settings.clone(), move || { let conn = lib_store.session()?; dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account)) .ok() .map(std::rc::Rc::new) }, // The weights are not shipped and are not a build input // (docs/faces.md §2): the user puts them beside the catalog, // and their absence is the ordinary state of a fresh install. { let lib = library.clone(); move || { let conn = lib.session()?; library::face_models(&conn.account) } }, // The sweep opens its own connection on its own thread, so it // takes paths rather than the handles this screen holds — and // a connection, because it fetches the pixels it indexes rather // than reading whatever the grid happened to leave behind. { let lib = library.clone(); move || { let conn = lib.session()?; let catalog = library::catalog_path(&conn.account); let thumbs = library::thumbs_dir(&conn.account); Some((conn, catalog, thumbs)) } }, // TRACES: FR-CULL-8 // The same device the develop view draws with. Face indexing // renders natively through FR-EXP-9's path, so it needs a GPU // for the same reason export does. gpu.clone(), ); } let weak = window.as_weak(); let store_ctl = controller.clone(); let lib = library.clone(); let coll = collections.clone(); launch_ui::wire(&window, controller.clone(), move |session| { let Some(w) = weak.upgrade() else { return }; log::info!("opening library for {}", session.describe()); library_ui::open(&w, lib.clone(), coll.clone(), &store_ctl.store, session); }); match startup { launch::Startup::ShowLaunchScreen => { log::info!("no library configured — showing the launch screen"); } launch::Startup::ShowLocalFiles => { log::info!("{} file(s) named on the command line", paths.len()); } // Skipping the launch screen must not mean skipping the library: // the "Open library" button lives on the screen we just bypassed, // so nothing else would ever start the scan. launch::Startup::OpenLibrary => { let session = controller.model.borrow().session().cloned(); if let Some(session) = session { log::info!("resuming library for {}", session.describe()); // Before anything opens a store: an upgrade must not // abandon a catalog, its thumbnails, or the offline // ratings and edits waiting beside them. library::migrate_legacy_cache_data(&session); library_ui::open( &window, library.clone(), collections.clone(), &controller.store, session, ); } } } } // TRACES: FR-CAT-10 | FR-CAT-11 | FR-NC-7a | FR-NC-7b // Import: a card into the library, and on to the server. // // Wired after the settings controller exists because it shares it — an // import's options are stored in the same file as everything else, and two // controllers each holding their own copy would each save over the other. // // The account is fetched afresh inside the closure rather than captured: // this runs once at startup, where a library can be opened, closed and // re-opened for the whole life of the window. { // Whether the header offers Import at all. A fact about the platform, // set once: it cannot change while the window is open, and on Android // it is false because there is no card to reach and nothing to write // through (`dr_plat::imports_supported`). window.set_import_supported(dr_plat::imports_supported()); let import = import_ui::ImportController::new(settings.clone(), activity.clone()); let library_for_context = library.clone(); let weak = window.as_weak(); import_ui::wire( &window, import, move || { let conn = library_for_context.session()?; Some(import_ui::Context { catalog: library::catalog_path(&conn.account), library_label: conn.account.root.clone(), // The same formats the scan looks for. An import that took // types the library then ignores would copy files off the // card that never appear in the grid. filter: conn.account.format_filter(), upload: Some(import::Upload { library: conn.account.root.clone(), // The same shard store the grid reads and the sync // pushes, so a thumbnail made during an import is the // one every other client gets. thumbs: library::thumbs_dir(&conn.account), staging: import::staging_dir(&conn.account), conn, }), }) }, move || { // The import wrote files into a folder on this machine and, if // the account allowed it, into the library on the server. Only // the second is what the grid shows, so this asks for the scan // that finds them rather than inserting rows itself. if let Some(w) = weak.upgrade() { w.invoke_library_rescan(); } }, ); } { // What the cache actually holds, so the ceiling above it is a figure // the user can judge rather than an abstract one. // // Empty at this point on a launch and that is expected: the catalog it // queries is being opened on a worker (`library_ui::open_catalog_soon`) // and is not there yet. The figure is filled in when the settings page // is opened, which is the only time it is looked at — see the `on_open` // closure passed to `settings_ui::wire`. settings.set_usage_label(describe_cache_usage(&library)); // Rendered once up front so the page is correct the first time it is // opened, rather than on the second open after a callback has run. settings_ui::render(&window, &settings); // The export button carries its destination, so it has to be correct // before the first click rather than after the first settings edit. refresh_export_label(&window, &settings); // Apply what is on disk before anything can use it. Without this the // controller's defaults stand until the user happens to open the // settings page and change something — so a cache deliberately capped // at 2 GB last session would spend this one filling to the default. { let stored = settings.snapshot(); library.set_cache_budget(stored.cache.original_budget_bytes); library.set_keep_opened_originals(stored.cache.keep_opened_originals); library.set_timeline_bars(stored.library.timeline_bars); } // --- the export folder picker ---------------------------------------- // // Wired here rather than inside `settings_ui::wire` because listing a // remote folder needs credentials, and the settings page deliberately // holds no session — it is reachable before a library is opened and must // not depend on one existing. { let weak = window.as_weak(); let ctl = settings.clone(); let library = library.clone(); // Every entry point needs the same three things, so they are fetched // once here rather than at four call sites. let start = move |ctl: &Rc, weak: &slint::Weak, library: &Rc, path: String| { match library.session() { Some(conn) => { settings_ui::spawn_folder_list(weak.clone(), ctl.clone(), conn, path); } None => { // No account, so nothing to browse. Said plainly rather // than left as an empty list, which would read as a server // with no folders on it. ctl.set_error("Sign in to a library before choosing a folder on it."); ctl.browser.replace(None); } } }; { let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start); window.on_settings_browse_open_picker(move || { let Some(w) = weak.upgrade() else { return }; // Opens on the library root rather than on whatever the // destination field happens to contain: a half-typed path // would list nothing and look like a broken picker. ctl.browser.replace(Some(launch::FolderBrowser { path: String::new(), entries: Vec::new(), loading: true, })); start(&ctl, &weak, &library, String::new()); settings_ui::render(&w, &ctl); }); } { let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start); window.on_settings_browse_into(move |name| { let Some(w) = weak.upgrade() else { return }; let path = { let mut browser = ctl.browser.borrow_mut(); let Some(b) = browser.as_mut() else { return }; let path = b.child_path(&name); b.path = path.clone(); b.entries.clear(); b.loading = true; path }; start(&ctl, &weak, &library, path); settings_ui::render(&w, &ctl); }); } { let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start); window.on_settings_browse_up(move || { let Some(w) = weak.upgrade() else { return }; let path = { let mut browser = ctl.browser.borrow_mut(); let Some(b) = browser.as_mut() else { return }; let Some(path) = b.parent_path() else { return }; b.path = path.clone(); b.entries.clear(); b.loading = true; path }; start(&ctl, &weak, &library, path); settings_ui::render(&w, &ctl); }); } { let (weak, ctl) = (weak.clone(), ctl.clone()); window.on_settings_browse_confirm(move || { let Some(w) = weak.upgrade() else { return }; // The folder being *shown* is the one chosen, matching the // library picker — so "use this one" means the same thing in // both places rather than depending on a selection the list // does not have. let chosen = ctl.browser.borrow().as_ref().map(|b| b.path.clone()); if let Some(path) = chosen { ctl.set_destination(path); } ctl.browser.replace(None); settings_ui::render(&w, &ctl); refresh_export_label(&w, &ctl); }); } { let (weak, ctl) = (weak.clone(), ctl.clone()); window.on_settings_browse_cancel(move || { let Some(w) = weak.upgrade() else { return }; ctl.browser.replace(None); settings_ui::render(&w, &ctl); }); } } let lib = library.clone(); let ctl = settings.clone(); let weak = window.as_weak(); settings_ui::wire( &window, settings.clone(), move |s| { // A ceiling that moved has to be applied to what is already on // disk, or lowering it would only affect future downloads and the // cache would sit over budget indefinitely. lib.set_cache_budget(s.cache.original_budget_bytes); lib.set_keep_opened_originals(s.cache.keep_opened_originals); if let Some(w) = weak.upgrade() { refresh_export_label(&w, &ctl); } // The axis is cut into bars when the window is loaded, so a new // count only shows once something reloads it. Done here, and only // when the figure actually moved: a setting that appears to do // nothing until the user scrolls is one they will change twice and // then leave wrong. if lib.set_timeline_bars(s.library.timeline_bars) { if let Some(w) = weak.upgrade() { library_ui::reload(&w, &lib); } } // Lowering the ceiling evicts, so the figure beside it has just // changed — leaving the old one would show the cache still over a // limit that was enforced a moment ago. ctl.set_usage_label(describe_cache_usage(&lib)); if let Some(w) = weak.upgrade() { settings_ui::render(&w, &ctl); } }, { // Face coverage, read when the page opens. The figures live in the // catalog and the settings page holds no session, so they arrive // through here rather than being kept up to date continuously — // they are only ever looked at while this page is on screen. let lib = library.clone(); let catalog = library.catalog(); let ctl = settings.clone(); move |w: &AppWindow| { let store = lib.session().and_then(|c| { dr_thumbs::ThumbStore::open(&library::thumbs_dir(&c.account)).ok() }); identity_ui::refresh_coverage(w, &catalog, store.as_ref()); w.set_identity_model_missing( lib.session() .and_then(|c| library::face_models(&c.account)) .is_none(), ); // Here rather than at startup, on exactly the reasoning // above: the catalog this reads is opened on a worker now, // so a launch has nothing to describe, and the figure is // only ever read while this page is on screen. `render` // again because `settings_ui::wire` renders *before* it // calls this, and the label is one of the things it draws. ctl.set_usage_label(describe_cache_usage(&lib)); settings_ui::render(w, &ctl); } }, ); } 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)); // TRACES: FR-PLAT-AND-5 // The GPU tier — the first thing given back under memory pressure, and on // Android the only thing given back merely for going into the background. // // `try_borrow_mut` rather than `borrow_mut`, and the miss is not an error // worth reporting. A memory warning can land in the middle of a render, at // which point the slot is already borrowed and freeing its textures under // the code drawing with them is not something to do politely — skipping is // correct, because the pass that is running will have finished by the time // the platform asks again, and a warning that has not been acted on is // always followed by another one. { let session = Rc::downgrade(&session); memory::evict_at(memory::Tier::Gpu, move || { let Some(session) = session.upgrade() else { return; }; let Ok(mut slot) = session.try_borrow_mut() else { return; }; if let Some(open) = slot.as_mut() { open.release_gpu_caches(); } }); } // TRACES: FR-DEV-6 | FR-CAT-8 // The settings clipboard, and where the open image's edit is stored. // // Both live for the life of the window rather than the view: a copy is // taken in develop and may be pasted onto a selection back in the grid, so // a clipboard owned by the develop view would be emptied by the very // navigation that carries it to its destination. let clipboard = presets::Clipboard::new(); let open_image: presets::OpenImage = Rc::new(RefCell::new(presets::Stored::Nowhere)); // One model for the lifetime of the window. Rows are mutated in place; // see `sync_rows` for why replacing it breaks dragging. let rows: Rc> = 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. // // In *physical* pixels, which is what the render target wants and what // FR-DSP-8 means by handling fractional scaling without resampling. The // one caller that thinks in logical pixels is Slint's resize callback, // and it converts on the way in. let viewport = Rc::new(RefCell::new((1024u32, 768u32))); // TRACES: FR-DSP-8 // What the displays are, and which one the canvas is on. Probed once here // so that the About page can describe the session's colour path before any // photograph is opened — the acquisition path is a property of the desktop // and not of the image. let display = display_ui::DisplayWatch::probe(); // 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. // TRACES: FR-DEV-5 // The history revision the panel was last built from. See the use below: // the list is rebuilt when it would read differently, not when the picture // is redrawn, and those are very different rates. let drawn_history: Rc>> = Rc::new(Cell::new(None)); // TRACES: FR-CULL-3 // How the photographer wants focus peaking drawn, or `None` for off. // // **Held here rather than on the session, which is the opposite of where // every edit lives.** A session is one photograph; peaking is a way of // *looking* at a folder of them. Someone culling three thousand frames // switches it on once, and a flag that reset with the session would ask // them to switch it on three thousand times — which is why // `reset_view_state` deliberately leaves it alone while emptying the // histogram beside it. let chosen_peaking: Rc>> = Rc::new(Cell::new(None)); let render_now: Render = { let session = session.clone(); let viewport = viewport.clone(); let drawn_history = drawn_history.clone(); let display = display.clone(); let chosen_peaking = chosen_peaking.clone(); Rc::new(move |window: &AppWindow, draft: bool| { let mut slot = session.borrow_mut(); let Some(s) = slot.as_mut() else { return }; // TRACES: FR-DSP-8 | FR-DSP-6 // **Every canvas render is encoded for the display showing it.** // // Set here rather than pushed from the display watch, and rather // than set once when a photograph is opened, because this is the // one path every frame takes. A session opened while the window // sits on the second monitor is then correct on its *first* frame // — where a push would leave it sRGB until the next poll, which is // a visible flash of the wrong colour on every image opened. // // Free when nothing has changed: the field is compared before it // is written, and an unchanged output space composes to the same // structure hash and the same cached pipeline. s.set_display_space(display.space()); // TRACES: FR-DEV-5 // Whether undo has anywhere to go, pushed from here because every // edit ends in a redraw and nothing else is on all of their paths: // the parameter callbacks sync rows, the framing ones sync the // geometry panel, and a paste arrives through neither. window.set_can_undo(s.can_undo()); window.set_can_redo(s.can_redo()); // TRACES: FR-DEV-5 | FR-DEV-7 // The list, on the same path and for the same reason — but only // when it would read differently. A drag ends in a redraw per // frame while folding into one step, so an unconditional rebuild // here would tear down and recreate every row of the panel sixty // times a second to arrive back at the list already on screen. let revision = s.history_revision(); if drawn_history.get() != Some(revision) { drawn_history.set(Some(revision)); window .set_history_rows(slint::ModelRc::new(slint::VecModel::from(s.history_rows()))); window.set_undo_label(s.undo_label().into()); } // TRACES: FR-DEV-3 // Which part of the region overlay the view is showing. Here // rather than in the panel's own sync because a pan or a zoom // changes it while changing no mask and no row — and every one of // those ends in a redraw. masks_ui::sync_overlay_view(window, s); // TRACES: FR-DEV-8 // And where the repairs are drawn, for the same reason: a pan or a // zoom moves every circle while touching no repair, and a circle // left where the mark used to be is worse than no circle at all. spots_ui::sync_handles(window, s); // The column's own numbers, pushed from here as well so that a // photograph opened with repairs already on it arrives with the // panel describing them — the sidecar lands after the callbacks // have all been installed, and a redraw is the one path every // arrival takes. spots_ui::sync_panel(window, s); // TRACES: FR-CULL-3 // The session owns the pass and the interface owns the choice, so // they are joined here — on the one path every frame takes, which // is also what makes a photograph opened with peaking already on // arrive with its marks rather than without them. if s.peaking() != chosen_peaking.get() { s.set_peaking(chosen_peaking.get()); } window.set_peaking_available(s.peaking_available()); let (mut w, mut h) = *viewport.borrow(); // **Half resolution while the gesture is still moving.** // // The adjust pass scales with pixel count, so halving each edge is // roughly a quarter of the work — the difference between keeping // up with a drag and lagging behind it. Less dramatic since S1 // removed the readback that scaled the same way and cost far more, // but a dispatch is still not free at 4K. // A draft frame is visible for one gesture and is replaced by a // full-resolution one the moment motion stops, so the cost is a // little softness exactly while the image is moving too fast to // study anyway. if draft { w = (w / 2).max(1); h = (h / 2).max(1); } // 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_view_mode() == ViewMode::Crop { 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)); // TRACES: FR-DSP-7 // **Counted on the settled frame and no other.** // // FR-DSP-7 requires the histogram not extend the FR-DSP-3 // frame budget, and `draft` is already exactly the flag // that says a gesture is still moving — so this reduction // and its 4 KB transfer happen once when the slider stops // rather than on every one of the forty frames a drag // emits. Nothing is lost by it: a histogram of a // half-resolution frame flickering past under a finger is // not a reading anyone takes. if !draft { window.set_histogram( s.histogram() .as_ref() .map_or_else(histogram::empty, histogram::view), ); // **The raw reading, pushed on the same path but not // recomputed on it.** `DevelopSession::raw_histogram` // caches: the demosaiced source is fixed for the life // of the session, so this costs one dispatch per // photograph and a clone of four kilobytes per settled // frame thereafter. It is pushed from here anyway // rather than once at open, because this is the one // path every photograph takes and a panel that had to // be told separately would be empty on some route into // develop mode. // // Three outcomes, said apart. A file that was never // raw has no white level and so no scale to measure // headroom against; a device that could not build the // reduction has a fault worth reporting; and only the // third is a reading. let raw = if !s.has_sensor_data() { histogram::raw_empty(histogram::RawAbsence::NotRaw) } else { s.raw_histogram().as_ref().map_or_else( || histogram::raw_empty(histogram::RawAbsence::NoDevice), histogram::raw_view, ) }; window.set_raw_histogram(raw); } // TRACES: FR-CULL-3 | NFR-P14 // **Marked on the settled frame and no other**, and unlike // the histogram beside it the marks are taken *down* in // between rather than left standing. // // The reason is not budget — the dispatch is a fraction of // a millisecond and would fit inside a draft frame // comfortably. It is that peaking measures the top octave // of the frame it is given, and a draft frame is rendered // at half resolution: a defocused edge that spans four // pixels there spans two, which is the signature of a // sharp one. Measuring it would mark the out-of-focus // background of every photograph, briefly, during every // drag. A stale overlay is no better, because a pan moves // the picture out from under it. // // So the marks pause while a control is moving and return // when it stops, which the panel says out loud rather than // leaving to be discovered. let overlay = (!draft).then(|| s.focus_overlay()).flatten(); match overlay { Some(image) => { window.set_focus_overlay(image); window.set_focus_overlay_ready(true); } None => window.set_focus_overlay_ready(false), } } Err(e) => { log::warn!("render failed: {e}"); window.set_load_error(e.into()); // No frame, so nothing to describe. The stale plot would // otherwise sit beside the error message looking current. window.set_histogram(histogram::empty()); // **The raw reading is left standing, and that is not an // oversight.** It describes the sensor data behind the // session, which a failed *render* says nothing about: the // demosaic succeeded or there would be no session at all. // Emptying it here would take away the one instrument // still telling the truth, at the moment the other one // stopped. // TRACES: FR-CULL-3 // And nothing to mark. Focus marks over the last frame // that rendered, beside a message saying this one did not, // is the same confident lie in a second instrument. window.set_focus_overlay_ready(false); } } }) }; // **Rendering is decoupled from input, and this is why.** // // A render used to be a blocking GPU round-trip — S1 removed the block, // but not the reason for this, so read it as history that still applies. // Running one straight from a `moved` handler put that stall *inside* the // gesture: touch events arrive far faster than a render completes, so the // input queue backed up, positions arrived stale, and Android — seeing the // events go unconsumed — reclaimed the gesture and delivered `cancel` // instead of `up`. That is the dropped-drag bug, and no amount of tuning // inside the Slint handlers fixes it while the stall is on the input path. // // So `redraw` no longer renders. It marks the canvas dirty and posts a // single render onto the event loop; every further request while one is // already pending just sets the flag again. A drag emitting forty events // therefore renders a handful of times instead of forty, and — the part // that actually fixes the drop — each event handler returns immediately, // so the gesture is always consumed promptly. // // The flag is re-checked *after* the render because parameters may have // moved again while it ran; that repost is what keeps the image converging // on the finger rather than settling on a stale frame. 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(); let open_image = open_image.clone(); let library_for_show = library.clone(); Rc::new(move |window: &AppWindow| { // The image about to be replaced is the last chance to persist its // edit — stepping to the next frame is as much a departure as // going back to the grid. presets::save_open_edit(window, &open_image.borrow(), &session, &library_for_show); let i = *index.borrow(); // Cloned rather than held: `load` below is slow, and keeping the // list borrowed across it would panic the moment anything else // touched `entries`. let Some(path) = entries.borrow().get(i).cloned() else { return; }; let path = path.as_path(); let name = path .file_name() .unwrap_or_default() .to_string_lossy() .to_string(); reset_view_state(window); window.set_filename(name.clone().into()); window.set_index(i as i32); match load(gpu.as_ref(), 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); // A local file stores its edit beside itself. Read // *before* the first render, so an edited // photograph never flashes up at its defaults. *open_image.borrow_mut() = presets::Stored::Local(path.to_path_buf()); if let Some(sidecar) = presets::load_local(path) { presets::apply_stored_edit(window, &sidecar, &session, &rows); } // Through `sync_rows` rather than setting rows // directly, so the curve's drawn shape is // refreshed by the same path that refreshes the // controls — one place to keep them in step. sync_rows(window, &rows, &session); redraw(window); } None => { // No sensor data: show the preview and disable // the controls rather than offering sliders that // would do nothing. *session.borrow_mut() = None; *open_image.borrow_mut() = presets::Stored::Nowhere; rows.set_vec(Vec::::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(); let open_image = open_image.clone(); *open_from_library.borrow_mut() = Some(Rc::new(move |path: String| { let Some(w) = weak.upgrade() else { return }; // Whatever was open before is being replaced; persist its edit // before the identity below is overwritten. presets::save_open_edit(&w, &open_image.borrow(), &session, &library); let name = path.rsplit('/').next().unwrap_or(&path).to_string(); reset_view_state(&w); w.set_filename(name.clone().into()); w.set_load_error("".into()); 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(conn) = library.credentials() else { w.set_load_error("no library session".into()); return; }; // TRACES: FR-CAT-8 | FR-NC-8 // Where this image's edit belongs. The uuid comes from the catalog // so every device names the same version; without one there is // nowhere to save to, and the image opens read-only as far as // persistence is concerned rather than writing to an invented // identity that would never merge. *open_image.borrow_mut() = match library.version_uuid_for_path(&path) { Some(version_uuid) => presets::Stored::Remote { path: path.clone(), version_uuid, }, None => { log::debug!("no catalog version for {path}; edits will not persist"); presets::Stored::Nowhere } }; // The sidecar is fetched alongside the image rather than after it. // It is a few kilobytes against tens of megabytes, so it costs // nothing to have in hand by the time there is a session to apply // it to — and starting it here means the edit is ready when the // photograph is, instead of the image appearing at its defaults // and visibly changing a moment later. let sidecar_rx = library::spawn_sidecar_fetch( conn.clone(), path.clone(), library.sidecar_cache_dir().unwrap_or_default(), library.is_offline(), ); // TRACES: FR-NC-6a // The cache is consulted first, so a second open of the same // photograph is a disk read rather than a second download of tens // of megabytes — and so a session's worth of images stays // openable when the connection goes. let cache = library.cache_context(&path); if cache.is_none() { log::debug!("no originals cache for {path}; fetching every time"); } log::info!("fetching {path} for develop"); w.set_load_error("Downloading…".into()); let rx = library::spawn_full_fetch(conn, path.clone(), cache); // The one transfer the user is actively waiting on. It gets a row // like any other, so a download that is still running after they // give up and go back to the grid is still accounted for. // // No denominator: `spawn_full_fetch` reports a result, not bytes as // they arrive, so the honest bar here is the indeterminate one. let job = activity.begin(activity::Kind::Download, format!("Downloading {name}")); // Polled on the UI thread rather than joined: a join would freeze // the window for the length of the download. let weak = w.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); let gpu = gpu.clone(); // Cloned for the timer closure: the outer callback is an `Fn` and // may run again for the next photograph. let library = library.clone(); let path = path.clone(); let sidecar_rx = Rc::new(sidecar_rx); let timer = Rc::new(slint::Timer::default()); let held = timer.clone(); timer.start( slint::TimerMode::Repeated, std::time::Duration::from_millis(50), move || { let Ok(got) = rx.try_recv() else { return }; // Landed — this timer has done its job. held.stop(); let Some(w) = weak.upgrade() else { return }; let bytes = match got { Ok(b) => b, Err(e) => { job.fail(e.message.clone()); log::warn!("{name}: {e}"); // Offline needs its own words. "network error: // connection refused" over a photograph the user // just clicked reads as a broken app; the real // situation is that this particular image was // never stored on this device, and the fix is to // download it while there is a connection. w.set_load_error(if e.offline { "Offline — this image is not stored on this device.".into() } else { slint::SharedString::from(e.message) }); return; } }; job.finish(activity::describe_bytes(bytes.len() as u64)); log::info!("{name}: {} bytes fetched", bytes.len()); 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(mut s) => { w.set_adjust_enabled(true); // TRACES: FR-CULL-10 // Who is in this photograph, so a // segmentation run can say "Anna" where // the model can only say "person". Read // here because this is the one moment the // catalog and the image id are both in // reach; every run afterwards gets them // for free. if let Some(id) = library.image_id_for_path(&path) { let cat = library.catalog(); let borrowed = cat.borrow(); if let Some(c) = borrowed.as_ref() { match crate::identity::named_boxes_normalised(c, id) { Ok(n) if !n.is_empty() => { log::info!( "{} known face(s) in this photograph", n.len() ); s.set_face_names(n); } Ok(_) => {} Err(e) => { log::debug!("reading known faces: {e}") } } } } *session.borrow_mut() = Some(s); // TRACES: FR-CAT-8 // The stored edit, if it has landed. It // was started before the download of a // file thousands of times its size, so in // practice it has; `apply_when_ready` // covers the case where it has not rather // than blocking the UI thread on a socket. apply_when_ready( &w, sidecar_rx.clone(), &session, &rows, &redraw, ); 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()); } } }, ); })); } // ---- copying settings between photographs (FR-DEV-6) ---------------- // // Wired after the develop session and the library both exist, because a // paste reaches both: onto the image on screen, or onto the grid's // selection through its sidecars. { presets::wire( &window, clipboard.clone(), presets::Develop { session: session.clone(), rows: rows.clone(), redraw: redraw.clone(), open: open_image.clone(), }, settings.clone(), library.clone(), collections.clone(), ); // TRACES: FR-DEV-6 // The saved half of the same requirement, wired from the same bundle: // applying a named preset to the open image is the paste path with a // different source. presets::wire_named( &window, presets::NamedPresets::open(), presets::Develop { session: session.clone(), rows: rows.clone(), redraw: redraw.clone(), open: open_image.clone(), }, settings.clone(), library.clone(), collections.clone(), ); // Close the knot left open beside `open_from_library`: the grid's // "‹ Library" button was wired before there was a session to save. let weak = window.as_weak(); let open_image = open_image.clone(); let session = session.clone(); let library = library.clone(); *leave_develop.borrow_mut() = Some(Rc::new(move || { let Some(w) = weak.upgrade() else { return }; presets::save_open_edit(&w, &open_image.borrow(), &session, &library); })); // TRACES: FR-DEV-6 // The scope chips, shared by the preset sheet and the settings page. // Wired once and rendered once: both surfaces draw one set, because // "what does a paste carry" is one question about how this // photographer works rather than one per place it is asked. presets::wire_scope(&window, settings.clone(), clipboard.clone()); presets::render_scope(&window, &settings); presets::render(&window, &clipboard, &settings); } // ---- Adjustment callbacks ------------------------------------------ // // Generic by construction: they carry indices into the capability list, // so adding an operation needs no change here (FR-DEV-3c). // --- export (FR-EXP-6, FR-EXP-7, FR-EXP-9) --------------------------- // // Two buttons, one worker. The develop view exports the image on screen and // the grid exports its selection; the only difference between them is who // renders the frames, so both hand a `Vec` to the same batch and // both report through the same activity row. // // A frame is always rendered at full resolution rather than taken from the // canvas: the display render is deliberately viewport-sized (FR-DSP-1), and // exporting that would hand the user a soft, screen-sized file with no // indication anything had been lost (FR-EXP-9). { // Replaced at each start, so the button always cancels the run it is // sitting on and a stale token cancels nothing. let cancel: Rc> = Rc::new(RefCell::new(export::Cancel::default())); // Holds the drain. Assigning a new timer drops the previous one, which // is what takes a superseded batch's row out of the register. let drain: Rc>> = Rc::new(RefCell::new(None)); let start = { let settings = settings.clone(); let library = library.clone(); let activity = activity.clone(); let gpu = gpu.clone(); let cancel = cancel.clone(); let drain = drain.clone(); Rc::new( move |window: &AppWindow, sources: Vec, to: export::Reporting| { let total = sources.len(); let request = batch_request(sources, &settings, &library, gpu.as_ref()); let token = export::Cancel::default(); *cancel.borrow_mut() = token.clone(); let rx = export::spawn_batch(request, token); // Upload the moment the batch is done rather than waiting // for a sync pass. An export bound for the server is // complete on disk the instant it is staged, but it is not // where the user asked for it until this runs — and // "Queued for Exports" sitting unchanged until somebody // presses Sync reads as an export that did not upload. let library_for_drain = library.clone(); export::drain_batch( window.as_weak(), &activity, &drain, rx, total, to, move || drain_outbox(&library_for_drain), ); }, ) }; { let weak = window.as_weak(); let session = session.clone(); let settings = settings.clone(); let start = start.clone(); window.on_export_image(move || { let Some(w) = weak.upgrade() else { return }; // One batch at a time, and the grid's counts as one. The two // buttons share a worker slot, so starting a second run would // drop the first's drain — leaving a batch still writing files // with no progress row and a button that never comes back. if w.get_export_busy() || w.get_library_exporting() { w.set_export_status("An export is already running".into()); return; } w.set_export_busy(true); // Set before the render rather than after: the render is the // one part still on this thread, so a label written afterwards // would never be drawn in the "Rendering…" state at all. w.set_export_status("Rendering…".into()); match render_open_frame(&w, &session, settings.snapshot().export.colour_space) { Ok(source) => start(&w, vec![source], export::Reporting::Develop), Err(e) => { w.set_export_status(format!("Export failed: {e}").into()); w.set_export_busy(false); } } refresh_export_label(&w, &settings); }); } // TRACES: FR-EXP-7 // The grid's selection. Nothing is rendered here — the worker fetches, // decodes and renders each photograph itself, so this returns to the // event loop immediately and a batch of three hundred is a progress bar // rather than a frozen window. { let weak = window.as_weak(); let library = library.clone(); let collections = collections.clone(); let start = start.clone(); window.on_library_export_selection(move || { let Some(w) = weak.upgrade() else { return }; // See the develop button above for why one run excludes the // other. The button is a cancel by then, so this only catches // a batch started from develop and left running. if w.get_library_exporting() || w.get_export_busy() { w.set_library_status("An export is already running".into()); return; } let sources = library.export_sources(&collections.selected()); if sources.is_empty() { // Said out loud rather than ignored, matching what a paste // or a judgement keystroke does with an empty selection. w.set_library_status("Select an image first".into()); return; } w.set_library_exporting(true); w.set_library_status(format!("Exporting {} images…", sources.len()).into()); start(&w, sources, export::Reporting::Library); }); } // TRACES: NFR-ARCH-3 { let weak = window.as_weak(); window.on_library_cancel_export(move || { cancel.borrow().cancel(); if let Some(w) = weak.upgrade() { // The worker stops at the next point it is safe to — which // may be a frame away — so the button says "asked for" and // not "done". The drain writes the real answer. w.set_library_status("Cancelling the export…".into()); } }); } } { 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); // TRACES: FR-DEV-3f // The film's own exposures ride *inside* the baked tables // rather than arriving as uniforms, because the print balance // is solved against them — an enlarger's filtration depends on // how the negative was exposed. So moving one has to rebuild // the lookup, which no other slider in the panel does. s.rebake_film_if_affected(op); } 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); }); } // TRACES: FR-DEV-3 // The local-adjustment panel. Wired as a block rather than inline because // it is a dozen callbacks that all say the same three things, and they // read better beside each other than scattered through this function. { // Choosing a group re-filters the panel and nothing else — no edit, // no render. It is navigation. let weak = window.as_weak(); let session = session.clone(); let rows = rows.clone(); window.on_adjust_tab_picked(move |index| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.set_active_tab(index); } sync_rows(&w, &rows, &session); }); } masks_ui::wire(&window, &session, &rows, &redraw); spots_ui::wire(&window, &session, redraw.clone()); // A way to land on the Identity Manager at startup, for looking at it // without a mouse. Off unless the variable is set, so it costs a getenv // per launch and changes nothing otherwise. if std::env::var_os("DARKROOM_START_IDENTITY").is_some() { window.invoke_identity_open(); } if std::env::var_os("DARKROOM_START_SETTINGS").is_some() { window.invoke_settings_open(); } { 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); }); } { // Which of the curve's curves the plot is showing. **No redraw**, and // that is the whole character of this control: it changes no // parameter, so the photograph is already correct on screen and // recomputing it would be a frame spent to produce the same pixels. // For the same reason it records no history step — there is nothing // to undo — and the sidecar never hears about it. let weak = window.as_weak(); let session = session.clone(); let rows = rows.clone(); window.on_curve_channel_picked(move |index| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.set_curve_channel(index); } sync_rows(&w, &rows, &session); }); } // ---- the film stock (FR-DEV-3f) ------------------------------------- // // Unlike the curve channel above, both of these change the photograph: // choosing a stock *is* the edit. So they redraw, and they leave the // session dirty for the sidecar in the ordinary way. { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); window.on_film_picked(move |index| { let Some(w) = weak.upgrade() else { return }; // Index zero is "None" — see `DevelopSession::film_choices`, which // puts it first so that the neutral choice needs no sentinel. let choices = DevelopSession::film_choices(); let Some((stock, _)) = choices.get(index.max(0) as usize) else { return; }; if let Some(s) = session.borrow_mut().as_mut() { // Printed by default when the stock has a paper: a colour // negative that has not been printed is an orange strip, and // offering that as the first thing a photographer sees when // they pick Portra would read as a bug rather than as a // choice. The toggle is there for anyone who wants the scan. let print = stock .and_then(dr_film::find) .and_then(dr_film::default_print) .is_some(); s.pick_film(*stock, print); } sync_rows(&w, &rows, &session); redraw(&w); }); } { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); window.on_film_print_toggled(move |print| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { if let Some((stock, _)) = s.film().map(|(a, b)| (a.to_string(), b)) { s.pick_film(Some(&stock), print); } } sync_film(&w, &session); redraw(&w); }); } // ---- undo and redo (FR-DEV-5) --------------------------------------- // // Thin, because the history lives in the session and every mutator there // records into it — see `DevelopSession::history`. What is left for the // interface is the refresh a paste also needs: the controls are showing // values that have just moved underneath them. // // `can-undo` and `can-redo` are not set here; `render_now` pushes them on // every redraw, which is every path that can change them. { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); window.on_undo(move || { let Some(w) = weak.upgrade() else { return }; let stepped = session.borrow_mut().as_mut().is_some_and(|s| s.undo()); if stepped { 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_redo(move || { let Some(w) = weak.upgrade() else { return }; let stepped = session.borrow_mut().as_mut().is_some_and(|s| s.redo()); if stepped { sync_rows(&w, &rows, &session); redraw(&w); } }); } // TRACES: FR-DEV-5 | FR-DEV-7 // Clicking a row. Arriving six steps away costs what arriving from one // does, because a step is a whole state — see `History::go_to`. { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); window.on_history_picked(move |index| { let Some(w) = weak.upgrade() else { return }; let stepped = session .borrow_mut() .as_mut() .is_some_and(|s| s.go_to_history(index)); if stepped { 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`. // TRACES: FR-DEV-3 // **The ratio lock belongs to the tool, not to the photograph.** A // `DevelopSession` is built per image, so holding the lock there would // reset it at every step through a shoot — and cropping a set of frames to // one shape is precisely when the lock earns its place. It lives here, for // the life of the window, and is re-applied to whichever session is open. // // It is deliberately *not* an edit. Nothing about it reaches the sidecar: // what is saved is the rectangle it produced, which is the whole of what // the pipeline needs and the whole of what another application could read. let crop_aspect = Rc::new(Cell::new(develop::CropAspect::default())); let crop_portrait = Rc::new(Cell::new(false)); window.set_crop_aspects( develop::CropAspect::CHOICES .iter() .map(|a| slint::SharedString::from(a.label())) .collect::>() .as_slice() .into(), ); { 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); }); } { // TRACES: FR-UI-5 // **Entering a mode is a side effect, which is why Rust owns it** and // the strip does not simply write the property. Each of the three has // work to do that the interface cannot see: // // *Crop* 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. // // *Local* turns the region overlay on. It used to be a button in the // masking panel, so the mode could be open with the overlay off — // which is a mode you have entered that is doing nothing. // // *Leaving* clears the selection, and that is the fault this whole // pass exists for: a selected layer silently re-points thirty sliders // at that layer's chain, so a mode you have left must not leave one // behind. After this the controls are unambiguously global again, // which is what the panel's heading then says. let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); window.on_mode_picked(move |mode| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { match mode { ViewMode::Crop => { 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); } ViewMode::Local => s.set_overlay(true), // TRACES: FR-DEV-8 // Nothing to arm: the circles are drawn whenever there are // repairs, and what the mode changes is whether a click on // the photograph makes another one. Leaving the mask // selection behind would re-point the column at a layer's // chain while the canvas is showing repairs, which is the // fault this whole strip exists to prevent. ViewMode::Spots => { s.set_overlay(false); s.set_active_mask(None); } ViewMode::Photo => { s.set_overlay(false); s.set_active_mask(None); // A repair stays on the photograph; only the *selection* // goes, so the source circle does not hang about over a // frame nobody is repairing any more. s.select_spot(None); } } } w.set_view_mode(mode); masks_ui::sync(&w, &session); if let Some(s) = session.borrow().as_ref() { spots_ui::sync_handles(&w, s); } // The scope may have just changed, so the panel below is now // describing a different chain. sync_rows(&w, &rows, &session); redraw(&w); }); } { // The rect arrives raw from the drag; the session normalises it, holds // it to whatever ratio is locked, 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 — and it is what lets the lock reshape a drag // without the overlay having to know a ratio exists. let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let crop_aspect = crop_aspect.clone(); let crop_portrait = crop_portrait.clone(); window.on_crop_changed(move |x, y, width, height, hx, hy| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { let rect = dr_pipeline::CropRect { x, y, width, height, }; // The overlay reports the corner it is *holding*; the point // that must not move is the opposite one. A move reports no // corner at all, and keeps the shape it already has — there is // nothing to reshape, and reshaping about a centre would drag // an over-moved rect smaller instead of sliding it along the // edge. if hx < 0.0 { s.set_crop(rect); } else { s.set_crop_locked( rect, crop_aspect.get(), crop_portrait.get(), (1.0 - hx, 1.0 - hy), ); } 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); }); } { // TRACES: FR-DEV-3 // Choosing a ratio reshapes the crop there and then rather than // waiting for the next drag. A lock that only took effect on the // following gesture would leave the chip lit over a rect that is not // that shape, which is the panel lying about the image. // // Held about the rect's centre, so the composition stays where it is // and the shape changes around it. let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let crop_aspect = crop_aspect.clone(); let crop_portrait = crop_portrait.clone(); window.on_crop_aspect_picked(move |index| { let Some(w) = weak.upgrade() else { return }; let Some(&aspect) = develop::CropAspect::CHOICES.get(index.max(0) as usize) else { return; }; crop_aspect.set(aspect); // A ratio with no second orientation cannot stay stood on its // short edge, or the switch would be off and the crop upright. if !aspect.has_orientation() { crop_portrait.set(false); } sync_crop_aspect(&w, &crop_aspect, &crop_portrait); apply_crop_aspect(&w, &session, &crop_aspect, &crop_portrait); redraw(&w); }); } { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let crop_aspect = crop_aspect.clone(); let crop_portrait = crop_portrait.clone(); window.on_crop_portrait_toggled(move || { let Some(w) = weak.upgrade() else { return }; if !crop_aspect.get().has_orientation() { return; } crop_portrait.set(!crop_portrait.get()); sync_crop_aspect(&w, &crop_aspect, &crop_portrait); apply_crop_aspect(&w, &session, &crop_aspect, &crop_portrait); 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(); let crop_aspect = crop_aspect.clone(); let crop_portrait = crop_portrait.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); } // TRACES: FR-DEV-3 // The crop travels with the frame, so a rect locked to 16:9 comes // out of the turn at 9:16. The switch has to agree, or the next // drag would snap it back upright and undo what the turn did to // the composition. An even number of turns lands where it started. let aspect = crop_aspect.get(); if aspect.turns_with_the_frame() && turns.rem_euclid(2) != 0 { crop_portrait.set(!crop_portrait.get()); sync_crop_aspect(&w, &crop_aspect, &crop_portrait); } 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); }); } { // TRACES: FR-DEV-3 // **The end of a straightening gesture crops away the corners it // exposed.** A free angle does not change the output size, by design — // that is what leaves the frame where the user put it while the slider // moves — so the corners of the finished photograph would otherwise be // black wedges of undefined area. // // Only on release, and only on release. Doing it per frame ratchets // the crop down through every angle the slider passed through rather // than the one it stopped at; see `auto_crop_to_angle`. // // Hung off the track's `committed`, not off `engaged-changed`. The // latter is *hover* — it has to be, because a `Flickable` withholds // the press — so a correction keyed on it fires when the pointer first // crosses the track, whether or not anything was dragged, and then // never fires again for as long as the pointer stays on it. Which // reads, exactly, as an auto-crop that works once. let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); window.on_straighten_committed(move |_degrees| { // The angle itself is already applied: `SliderTrack` emits its // last `changed` before it commits, so `on_straighten_changed` has // run with this very value. What is left is the correction that // has to happen exactly once. let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.auto_crop_to_angle(); } 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). // // TRACES: FR-DSP-8 // Slint reports the canvas in *logical* pixels, which is the box the // compositor will draw into and not the number of device pixels it will // fill. `display_ui::physical` converts, so that a fractionally scaled // desktop is presented 1:1 rather than resampled — see that function for // why a soft canvas is the failure being avoided here. { let weak = window.as_weak(); let viewport = viewport.clone(); let redraw = redraw.clone(); let display = display.clone(); window.on_canvas_resized(move |w_px, h_px| { let Some(w) = weak.upgrade() else { return }; let logical = (w_px.max(1) as u32, h_px.max(1) as u32); let size = display.canvas_resized(&w, logical); if *viewport.borrow() == size { return; } *viewport.borrow_mut() = size; redraw(&w); }); } // TRACES: FR-CULL-3 // The peaking switch and its two choices. // // All three write `chosen_peaking` and then redraw, because the marks are // produced by a compute pass over the rendered frame: there is nothing the // interface can change about the overlay that does not require the frame // to be measured again. Turning peaking *off* redraws for the same reason // — that render is what drops the overlay textures and clears the flag. { let weak = window.as_weak(); let chosen = chosen_peaking.clone(); let redraw = redraw.clone(); window.on_peaking_toggled(move |on| { let Some(w) = weak.upgrade() else { return }; // Built from the chips as they currently stand rather than from a // remembered value: they are what the photographer can see, and an // overlay that came back in a configuration the panel is not // showing would be the panel lying about itself. let next = on.then(|| dr_gpu::FocusPeaking { sensitivity: peaking::sensitivity(w.get_peaking_sensitivity()), colour: peaking::colour(w.get_peaking_colour()), }); chosen.set(next); w.set_peaking_on(next.is_some()); redraw(&w); }); } { let weak = window.as_weak(); let chosen = chosen_peaking.clone(); let redraw = redraw.clone(); window.on_peaking_sensitivity_picked(move |index| { let Some(w) = weak.upgrade() else { return }; w.set_peaking_sensitivity(index); // Only reachable while peaking is on — the chips are not drawn // otherwise — but written as a conditional rather than an // `expect`, because a panel is free to change its mind about that // and nothing here should fall over when it does. if let Some(mut current) = chosen.get() { current.sensitivity = peaking::sensitivity(index); chosen.set(Some(current)); redraw(&w); } }); } { let weak = window.as_weak(); let chosen = chosen_peaking.clone(); let redraw = redraw.clone(); window.on_peaking_colour_picked(move |index| { let Some(w) = weak.upgrade() else { return }; w.set_peaking_colour(index); if let Some(mut current) = chosen.get() { current.colour = peaking::colour(index); chosen.set(Some(current)); redraw(&w); } }); } // The chips open on whatever the vocabulary calls its default, so the // panel and the pass agree before anything has been pressed. window.set_peaking_sensitivity(peaking::sensitivity_index(Default::default())); window.set_peaking_colour(peaking::colour_index(Default::default())); // TRACES: FR-DSP-8 | FR-DSP-6 // And which display that canvas is on, from now until the window closes. display_ui::attach(&window, &display, &viewport, redraw.clone()); // FR-UI-1: layout class from window width. Computed here rather than in // Slint because a property that both derives from and feeds the layout is // a binding loop. let panels = std::rc::Rc::new(PanelChoices::default()); { 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); } // The figure that matters, because it is the one the input dispatcher is // counting against. Anything approaching five seconds here is an ANR on the // next Android launch whatever the phases above say, and anything that // pushes it up belongs on a worker. log::info!( "startup: {} ms to the event loop", launch_began.elapsed().as_millis() ); 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()); // FR-UI-1: the ceiling on the develop column, computed here for the same // reason the class is — a width that both derives from and feeds the // layout is a binding loop in Slint. window.set_panel_max_width((width * PANEL_MAX_FRACTION).max(PANEL_MIN_WIDTH)); if panels.class.get() != Some(expanded) { panels.class.set(Some(expanded)); panels.panel.set(None); panels.collections.set(None); } window.set_panel_visible(panels.panel.get().unwrap_or(expanded)); window.set_collections_visible(panels.collections.get().unwrap_or(expanded)); } /// TRACES: FR-UI-5 /// One step back, and whether there was one to take. /// /// The Escape half is FR-UI-5's "keyboard shortcuts cover navigation". The /// Android back gesture answers to the same handler and has no numbered /// requirement of its own — the register was written before phones and tablets /// had a platform section, and §1.3 still lists no navigation requirement. /// /// This is what Android's back gesture and the Escape key both resolve to. The /// order is the order the states were entered in, innermost first: a mode /// within a view is left before the view is, because that is what the user /// most recently did and so what they most likely mean to undo. /// /// Returning `false` means this is the top of the stack. The shell passes that /// straight back to the platform as an unhandled key, which on Android closes /// the activity — the behaviour every application there has, and the reason /// this answers with a bool rather than swallowing the gesture. fn back_one_step(w: &AppWindow) -> bool { let state = NavState { settings: w.get_show_settings(), launch: w.get_show_launch(), browsing: w.get_launch_browsing(), library: w.get_show_library(), mode: w.get_view_mode(), zoomed: w.get_zoomed(), // Files named on the command line have no grid behind them — the same // condition the status strip uses to decide whether to offer the way // back at all. has_grid: w.get_library_total() > 0, scoped: w.get_collection_selected() != 0, }; let Some(step) = back_step(state) else { return false; }; match step { BackStep::CloseSettings => w.invoke_settings_close(), BackStep::CancelBrowse => w.invoke_launch_browse_cancel(), BackStep::LeaveMode => w.invoke_mode_picked(ViewMode::Photo), 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. // No `Eq`: `ViewMode` is generated by Slint and derives only `PartialEq`, // which is all the comparisons below need. `Default` still derives, and it // gives `mode` the enum's own first variant — `photo`, which is what "no mode" // means and what the tests below want as their baseline. #[derive(Clone, Copy, Debug, Default, PartialEq)] struct NavState { settings: bool, launch: bool, browsing: bool, library: bool, /// Which develop mode is on, if any. One field rather than one flag per /// mode, so "leave the innermost" cannot be asked of two at once — the /// ordering below would have had to invent an answer for a state the /// interface can no longer be in. mode: ViewMode, zoomed: bool, has_grid: bool, scoped: bool, } /// What one step back does, or `None` at the top of the stack. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum BackStep { CloseSettings, CancelBrowse, /// Leave whichever develop mode is on — crop or local — and return to the /// whole photograph. One step for both, because there is one mode at a /// time and "back" means the same thing from either. LeaveMode, ResetZoom, ToLibrary, ClearScope, } fn back_step(s: NavState) -> Option { // Settings is drawn over everything, so it is left first whatever is // behind it. if s.settings { return Some(BackStep::CloseSettings); } if s.launch { // The folder picker is a step inside the launch screen; the launch // screen itself is where the application starts and has nothing behind. return s.browsing.then_some(BackStep::CancelBrowse); } if !s.library { // Develop. Crop and local are modes and zoom is a view state; all are // left before the image is. // // The mode goes before the zoom because that is the order they were // entered in — a photographer zooms to place a mask, not the other way // about — and because leaving local mode drops the mask selection, // which is a bigger step back than returning to fit. if s.mode != ViewMode::Photo { return Some(BackStep::LeaveMode); } if s.zoomed { return Some(BackStep::ResetZoom); } return s.has_grid.then_some(BackStep::ToLibrary); } // The grid. A collection scoping it is a step in: back widens to the whole // library before it considers leaving. // // And it does not leave: the grid is home, so back from here closes the // application as it does in every other Android app. Signing out is a // deliberate act reached from "Change library", not somewhere a stray swipe // should land. s.scoped.then_some(BackStep::ClearScope) } #[cfg(test)] mod tests { use super::*; /// Develop with a grid behind it — the state most of the back tests vary. fn developing() -> NavState { NavState { has_grid: true, ..NavState::default() } } #[test] fn back_closes_settings_before_anything_underneath_it() { // Settings is reachable from both the grid and develop, and is drawn // over whichever it was opened from. Whatever is behind must wait. let from_grid = NavState { settings: true, library: true, scoped: true, ..developing() }; assert_eq!(back_step(from_grid), Some(BackStep::CloseSettings)); let from_develop = NavState { settings: true, mode: ViewMode::Crop, ..developing() }; assert_eq!(back_step(from_develop), Some(BackStep::CloseSettings)); } #[test] fn back_leaves_a_mode_before_it_leaves_the_image() { // A mode, then zoom, then the view: innermost first, because that is // the order they were entered in. for mode in [ViewMode::Crop, ViewMode::Local] { let in_mode = NavState { mode, zoomed: true, ..developing() }; assert_eq!( back_step(in_mode), Some(BackStep::LeaveMode), "{mode:?} must be left before the zoom is reset" ); } let zoomed = NavState { zoomed: true, ..developing() }; assert_eq!(back_step(zoomed), Some(BackStep::ResetZoom)); assert_eq!(back_step(developing()), Some(BackStep::ToLibrary)); } /// TRACES: FR-UI-5 /// Local masking joins the existing order rather than inventing an exit. /// /// It is the point of making it a mode: before this, back and Escape did /// nothing about a masking session, so the only way out of it was to find /// the two toggles that had armed it and press them again — and neither /// was anywhere near the photograph the user was looking at. #[test] fn local_masking_is_left_by_the_same_step_crop_is() { let masking = NavState { mode: ViewMode::Local, ..developing() }; assert_eq!(back_step(masking), Some(BackStep::LeaveMode)); } #[test] fn back_from_an_image_with_no_grid_behind_it_is_the_top_of_the_stack() { // Files named on the command line: there is no library to return to, // and the status strip does not offer one either. let standalone = NavState { has_grid: false, ..developing() }; assert_eq!(back_step(standalone), None); } #[test] fn back_widens_a_scoped_grid_before_it_would_leave_the_grid() { let scoped = NavState { library: true, scoped: true, has_grid: true, ..Default::default() }; assert_eq!(back_step(scoped), Some(BackStep::ClearScope)); } #[test] fn back_from_the_whole_grid_closes_the_application() { // The grid is home. Nothing here may navigate to the launch screen: // that is where signing out lives, and a stray back swipe must not // land on it. let home = NavState { library: true, has_grid: true, ..Default::default() }; assert_eq!(back_step(home), None); } #[test] fn back_cancels_the_folder_picker_but_never_leaves_the_launch_screen() { let picking = NavState { launch: true, browsing: true, ..Default::default() }; assert_eq!(back_step(picking), Some(BackStep::CancelBrowse)); let launch = NavState { launch: true, ..Default::default() }; assert_eq!(back_step(launch), None); } fn meta() -> Metadata { Metadata { make: Some("Canon".into()), model: Some("Canon EOS 6D".into()), shutter: Some(1.0 / 250.0), aperture: Some(2.8), iso: Some(400), focal_length: Some(50.0), ..Default::default() } } #[test] fn camera_does_not_repeat_the_make() { // rawler reports make "Canon" and model "Canon EOS 6D"; naive // concatenation gives "Canon Canon EOS 6D". assert_eq!(describe_camera(&meta()), "Canon EOS 6D"); } #[test] fn camera_joins_when_model_omits_the_make() { let m = Metadata { make: Some("NIKON".into()), model: Some("D850".into()), ..Default::default() }; assert_eq!(describe_camera(&m), "NIKON D850"); } #[test] fn missing_camera_metadata_is_empty_not_a_placeholder() { assert_eq!(describe_camera(&Metadata::default()), ""); } #[test] fn shutter_reads_as_a_fraction_below_one_second() { assert!(describe_exposure(&meta()).starts_with("1/250")); } #[test] fn long_exposures_read_as_seconds() { let m = Metadata { shutter: Some(2.5), ..Default::default() }; assert_eq!(describe_exposure(&m), "2.5s"); } #[test] fn exposure_omits_absent_fields() { let m = Metadata { iso: Some(100), ..Default::default() }; assert_eq!(describe_exposure(&m), "ISO 100"); assert_eq!(describe_exposure(&Metadata::default()), ""); } #[test] fn only_supported_extensions_are_collected() { assert!(is_supported(Path::new("a.CR2"))); assert!(is_supported(Path::new("a.jpg"))); assert!(!is_supported(Path::new("a.txt"))); assert!(!is_supported(Path::new("noextension"))); } fn points(values: &[f32]) -> slint::ModelRc { 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 ); } }