//! Slint interface for DarkRoom. //! //! A viewer with a develop panel: open a folder of RAW files, decode and //! demosaic on the GPU, and adjust. //! //! **The develop frame never leaves the GPU** (ARCH §6.1, AC-8). Spike S1 //! wired Slint's texture import: [`shared_gpu`] opens one wgpu device and //! gives it to *both* the compute passes and Slint's renderer, and //! `DevelopSession::render` then hands the compositor the very texture the //! adjust pass wrote. What used to be a readback and an upload per frame is //! now a refcount. //! //! `SharedPixelBuffer` still appears in this file and in the library grid, and //! that is not a relapse: an embedded JPEG preview and a thumbnail are decoded //! on the CPU and have no texture to hand over. AC-8 is about pixels that were //! *computed on the GPU* travelling to the CPU and back to be looked at. //! //! **The develop panel is generated, not written.** [`develop`] asks the //! pipeline what parameters it has and builds a control per answer; no code //! in `ui/` names an operation or knows a shader exists (FR-DEV-3a). mod activity; #[cfg(all(feature = "automation", unix))] mod automation; mod bursts; mod collections_ui; #[cfg(test)] mod decoder_seam; mod derived_sync; mod develop; mod develop_ui; mod display_ui; mod export; pub mod faces; pub use library::render_native; // Generated from the `GESTURE:` comments beside the code that implements each // one — see `tools/traceability`. Regenerate with // `cargo run -p traceability -- gestures`; CI fails if it has drifted. mod gesture_book; mod gestures; mod gradient; mod histogram; pub mod identity; mod identity_ui; mod import; mod import_ui; pub mod inference; mod labels; mod library; mod library_ui; #[cfg(live_style)] mod live_style; pub mod manual; mod masks_ui; pub mod memory; pub mod merge; mod merge_ui; mod net_runtime; mod peaking; mod place; mod preset_store; mod presets; mod recovery_ui; mod refine; mod remote; pub mod repairs; mod segmentation; mod settings_store; mod settings_ui; mod sidecar_cache; mod spots_ui; mod trash; mod xmp_sync; use std::cell::{Cell, RefCell}; use std::path::{Path, PathBuf}; use std::rc::Rc; use anyhow::Result; use dr_decode::{Decoder, Metadata, PreviewSize}; use slint::ComponentHandle as _; pub use develop::DevelopSession; /// Where a model has to land for face indexing to find it, on any account. /// /// Public for the Android entry point, which is the only caller that knows the /// APK may carry a bundled copy and has to write it out before any store is /// opened — see `library::shared_face_models_dir`. pub use library::inpaint_model; pub use library::shared_face_models_dir; pub use library::FaceModelPaths; /// The scene model's three files, wherever this device keeps them. /// /// Public for the same reason as the directory above: the pieces that reach for /// a model are not all inside this crate. Exported ahead of the scene tab that /// will consume it so that the packaging and unpacking added alongside it have /// something to be verified against — `assemble-apk.sh` writing files no lookup /// looks for would be a silent mistake for as long as the tab took to arrive. pub use library::scene_model; pub mod launch; pub mod launch_ui; slint::include_modules!(); /// TRACES: FR-DSP-1 | NFR-RES-1 /// Longest edge the viewer renders at. /// /// FR-DSP-1: work at the resolution the viewport needs, not the source /// resolution. A 5472×3648 preview is 79.8 MB of RGBA; at 2048 it is 11 MB, /// which is what keeps a folder browsable within NFR-RES-1's budget. const MAX_DISPLAY_DIM: u32 = 2048; /// TRACES: FR-UI-1 | FR-UI-2 | M-16 /// Width at which the expanded layout appears (FR-UI-1). /// /// Logical pixels, not a device check: a desktop window dragged under this gets /// the compact layout exactly as a small screen would, and that is FR-UI-1's /// rule rather than a convenience. /// /// **The tablet in portrait is not that case**, and it is worth saying so here /// because this comment used to claim it was. The tablet's panel is about 960 /// logical pixels across in portrait, which clears 820 — so portrait is /// expanded, and turning the device does not change the class at all. What it /// changes is which axis the develop column is on, which is `column_below` and /// D-N7, and nothing to do with this number. const EXPANDED_MIN_WIDTH: f32 = 820.0; /// TRACES: FR-UI-1 /// The largest share of the window the develop column may take. /// /// A policy, not a size. The column is a fixed `panel-width` (`style.yaml`), /// so on any window with room for it this does nothing at all — it bites only /// where 360px would be most of the screen, and what it says there is that the /// majority of the window stays with the photograph the column exists to /// serve. /// /// It used to do more, because the column used to size itself to the widest /// thing it held and could therefore grow without limit on a rich operation /// set. It cannot any more; this is the remaining half of that guard, kept for /// the case the fixed width does not cover. const PANEL_MAX_FRACTION: f32 = 0.45; /// The floor under that share, so a narrow window still gets a usable column /// rather than one squeezed below the width its own controls were drawn for. /// Under `panel-width` by design: between the two the column narrows from 360 /// to 280 on a small window and stops, which is the range the sliders inside /// it stay accurate over. const PANEL_MIN_WIDTH: f32 = 280.0; /// TRACES: FR-UI-1 /// The window aspect — height over width — at which the develop column moves /// from beside the photograph to under it (D-N7). /// /// Above 1.0 rather than at it, because a window barely taller than it is wide /// has nothing to gain. D-N7's arithmetic only comes out for the dock once the /// photograph left beside a 360px column has become a strip, and 1.25 is where /// that starts: the tablet in portrait is about 960 × 1500, which is 1.56. const DOCK_ENTER_ASPECT: f32 = 1.25; /// And the aspect at which it moves back beside the photograph. /// /// Below `DOCK_ENTER_ASPECT` rather than equal to it, and that gap is the /// whole point. This is read on every resize event, so a single threshold /// means a window dragged along its own diagonal crosses it several times a /// second and the column jumps from one axis to the other and back while the /// pointer is still down. The dead band between 1.15 and 1.25 is wide enough /// that no plausible drag re-crosses it and narrow enough that the flag still /// answers to the window's shape rather than to its history. const DOCK_LEAVE_ASPECT: f32 = 1.15; /// How long after the last change a draft frame is replaced by a sharp one. /// /// Above the interval between events in a drag, so an ordinary gesture never /// reaches it and never renders full resolution mid-motion; well below the /// point where a photographer would notice waiting for the sharp frame. const SETTLE_DELAY: std::time::Duration = std::time::Duration::from_millis(120); /// Re-render the current session into the canvas; `true` asks for a draft. /// /// Shared rather than passed by reference because most of the callbacks in /// `run` need it and they each outlive the call that built them, so every one /// holds its own handle. type Render = Rc; /// 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>, decoder: &dyn Decoder, 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, decoder, &bytes) } /// Open already-fetched bytes. /// /// Split from [`load`] because a library image has no local file: it arrives /// as a WebDAV response body, and writing it to disk purely to read it back /// would be a round-trip for nothing. fn load_bytes( ctx: Option<&dr_gpu::GpuContext>, decoder: &dyn Decoder, bytes: &[u8], ) -> Result { let meta = decoder.metadata(bytes).unwrap_or_default(); // How the file stored its pixels, for the preview path below — the develop // path takes it from the same header inside `open_session`. A file that // says nothing is taken as upright — see `Orientation::from_exif`. let orientation = meta.orientation.unwrap_or_default(); if let Some(ctx) = ctx { match open_session(ctx, decoder, bytes, &meta) { Ok(session) => { let (width, height) = session.source_size(); return Ok(Loaded { session: Some(session), fallback: None, meta, width, height, }); } Err(e) => log::info!("develop unavailable, showing preview: {e}"), } } // Fall back to the embedded preview: no GPU, or a file neither decoder // could open for editing. Read-only, and the adjust panel is disabled. let mut preview = decoder .preview(bytes, PreviewSize::Screen) .map_err(|e| e.to_string())?; preview.downscale_to(MAX_DISPLAY_DIM); // No graph here to carry the baseline, so the pixels are turned instead. // Cheaper than it sounds after the downscale, and this path is the one a // phone without a working GPU lands on — where sideways is most likely. preview.apply_orientation(orientation); let buffer = slint::SharedPixelBuffer::::clone_from_slice( &preview.rgba, preview.width, preview.height, ); Ok(Loaded { session: None, fallback: Some(slint::Image::from_rgba8(buffer)), meta, width: preview.width, height: preview.height, }) } /// TRACES: FR-EXP-7 | FR-EXP-8 | FR-RAW-4 /// Open bytes for editing, with no interface types involved. /// /// Split out of [`load_bytes`] so the batch exporter can share it. That runs on /// a worker thread, where a `slint::Image` has no business being constructed — /// and a second copy of the JPEG-versus-RAW routing is a second copy that would /// drift, which is exactly how a batch comes to export something the viewer /// would have shown differently. /// /// Takes the decoded header rather than just the orientation out of it. Both /// callers had already read it, and the session keeps it: this is the one /// place a photograph becomes editable, so making the header an argument here /// is what makes "a session knows which file it came from" true by /// construction rather than by everybody remembering to say so. pub(crate) fn open_session( ctx: &dr_gpu::GpuContext, decoder: &dyn Decoder, bytes: &[u8], meta: &Metadata, ) -> Result { // How the file stored its pixels. A file that says nothing is taken as // upright — see `Orientation::from_exif`. let mut session = open_pixels(ctx, decoder, bytes, meta.orientation.unwrap_or_default())?; // TRACES: FR-EXP-8 // The header goes with the session rather than being read again later, // and this function takes it rather than an orientation so that there is // no way to open a photograph for editing without saying what file it came // from. An export from the develop button carries the camera, the lens and // the capture date because of this line; before it, the same photograph // exported from the grid kept them and exported from develop did not. session.set_source_metadata(meta.clone()); Ok(session) } /// Decode bytes into a session, with no header attached. /// /// Split from [`open_session`] only so the routing below stays one expression /// with two early returns; every caller wants the version that remembers where /// the pixels came from. fn open_pixels( ctx: &dr_gpu::GpuContext, decoder: &dyn Decoder, bytes: &[u8], orientation: dr_types::Orientation, ) -> Result { // Route by what the bytes actually are, not by extension (M-9). A JPEG has // no sensor data and never will, so trying the RAW decoder first would be a // guaranteed failure whose log line reads like a fault. if dr_decode::probe(bytes) == Some(dr_types::Format::Jpeg) { return dr_decode::decode_jpeg(bytes) .map_err(|e| e.to_string()) .and_then(|mut p| { // Fit the device before uploading. A film scan runs to // 13728×8928, well past the 8192 a typical GPU can hold, and // refusing it would drop the image back to a read-only preview // — the very thing this path exists to avoid. 8192 is still // four times a 4K long edge. let limit = dr_gpu::DemosaicedImage::max_dimension(ctx); if p.width.max(p.height) > limit { log::info!( "{}×{} exceeds the {limit} texture limit; fitting to it", p.width, p.height ); p.downscale_to(limit); } DevelopSession::open_rgb(ctx, &p.rgba, p.width, p.height, orientation) }); } // A failure here is expected for bodies rawler does not know, and must not // stop the image displaying (FR-RAW-4). decoder .decode(bytes) .map_err(|e| e.to_string()) .and_then(|raw| DevelopSession::open(ctx, &raw, orientation)) } /// Collect displayable images from file or directory arguments. fn collect(paths: &[PathBuf]) -> Vec { 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.global::().set_view_mode(ViewMode::Photo); window.set_zoom(1.0); window.set_zoomed(false); window.set_crop_x(0.0); window.set_crop_y(0.0); window.set_crop_w(1.0); window.set_crop_h(1.0); let framing = window.global::(); framing.set_max_straighten(dr_pipeline::framing::MAX_STRAIGHTEN); framing.set_angle(0.0); framing.set_max_keystone(dr_pipeline::framing::MAX_KEYSTONE); framing.set_keystone_v(0.0); framing.set_keystone_h(0.0); framing.set_flip_h(false); framing.set_flip_v(false); framing.set_modified(false); // A photograph that failed to decode has no session, so nothing below // will speak for it — and the buttons would otherwise keep offering the // previous image's history. let steps = window.global::(); steps.set_can_undo(false); steps.set_can_redo(false); // TRACES: FR-DEV-7 // And the comparison goes down with them. Unlike the inspection point // below it, this is not a way of looking at a folder: it is a question // about one photograph's edit, asked while holding something. A key // release that arrived after the next frame had opened would put the view // back anyway, but a press that opens the next photograph — the roll is a // tap away — must not carry a held original onto it. window.set_showing_original(false); // TRACES: FR-DEV-3 // And an armed sampler goes down with it: a picker left waiting would make // the first click on the *next* photograph sample it, which is a click // nobody meant to spend. window.global::().set_sampling_op(-1); // TRACES: FR-DEV-16 // And the control last moved, which R resets: the indices are into this // photograph's rows, and a reset that reached back into the previous edit // would land on whichever control happens to share them. window.global::().set_touched_op(-1); window.global::().set_touched_param(-1); window.global::().set_touched(0); // TRACES: FR-DSP-7 // Emptied rather than left standing: the previous photograph's histogram // beside the next one's filename is a confident, precise lie, and the gap // before the new frame settles is exactly long enough to read it. window.global::().set_data(histogram::empty()); // And the raw reading with it. It belongs to one file more completely than // the display histogram does — nothing about the edit can move it — which // makes leaving it standing over the next photograph's filename worse // rather than better: it would look exactly as current as it is wrong. window .global::() .set_raw_data(histogram::raw_empty(histogram::RawAbsence::NoImage)); // TRACES: FR-CULL-3 // The marks go down with it, and for the same reason. What is *not* reset // is whether peaking is switched on: that is a way of looking at a folder // rather than a property of one photograph, so it survives to the next // frame — see `chosen_peaking` for the whole of that argument. window.set_focus_overlay_ready(false); // TRACES: FR-DEV-3 // The region map belongs to one photograph. Carrying the stack, the // overlay or the crosshair to the next one would offer a selection of // regions that are not in the picture on screen. masks_ui::reset(window); // TRACES: FR-DEV-8 // The repairs belong to one photograph too. The next one's arrive with its // sidecar a moment later, and until they do the canvas must not be showing // the last one's. spots_ui::reset(window); } /// TRACES: FR-UI-4 /// Where the 1:1 view is pointing, and whether it is currently on. /// /// **Two facts rather than an `Option`, and the second one is why.** Returning /// to fit turns the magnifier off; it does not mean the photographer has /// stopped caring about the corner they were inspecting. An `Option` emptied /// on the way out would send the next press of the same control back to the /// centre of the frame, which is the one place nobody was looking. /// /// The point is in fractions of the *framed* image, because that is the only /// coordinate system two different photographs share. #[derive(Debug, Clone, Copy)] struct Inspection { at: (f32, f32), on: bool, } impl Default for Inspection { fn default() -> Self { // The centre, which is where a magnifier that has never been aimed // points. `(0.0, 0.0)` would be the top-left corner — a real place, // and never the one meant. Self { at: (0.5, 0.5), on: false, } } } /// TRACES: FR-UI-4 /// Put a newly opened photograph under the magnifier the last one was under. /// /// The other half of `reset_view_state`, and deliberately not part of it: that /// function empties what belonged to one photograph, and the inspection point /// belongs to the *pass*. Checking the same eye on forty portraits is why a /// 1:1 view is worth reaching at all, and a magnification that reset with the /// session would make it forty zooms and forty pans instead of forty /// keystrokes. /// /// Called after the stored edit has landed rather than before it, because the /// point is in fractions of the *framed* image: a sidecar carrying a crop /// changes what a fraction refers to, and inspecting before it arrived would /// land somewhere the photograph does not have any more. /// /// Silently does nothing where the file could not be opened for editing — /// there is no view to place, and the next photograph that does open is still /// inspected. fn resume_inspection( session: &Rc>>, viewport: &Rc>, inspection: &Rc>, ) { let held = inspection.get(); if !held.on { return; } let (vw, vh) = *viewport.borrow(); if let Some(s) = session.borrow_mut().as_mut() { s.inspect_at(held.at.0, held.at.1, vw, vh); } } /// Push the framing back to the geometry panel. /// /// Separate from [`sync_rows`] because framing is no longer *in* the rows — /// it is presented by its own panel rather than generated (see /// `DevelopSession::rows`), so nothing else would carry these values across. /// /// Everything here is written from what the session actually holds rather than /// from what the gesture asked for: quarter turns wrap, the angle is clamped /// to the descriptor's range, and the crop is normalised, so the panel must /// show the applied value or it will disagree with the image. fn sync_framing(window: &AppWindow, session: &Rc>>) { let Some(s) = session.borrow().as_ref().map(|s| { let (h, v) = s.flips(); let c = s.crop(); (s.angle(), s.keystone(), h, v, s.framing_edits_image(), c) }) else { return; }; let (angle, (keystone_v, keystone_h), flip_h, flip_v, modified, crop) = s; let framing = window.global::(); framing.set_angle(angle); framing.set_keystone_v(keystone_v); framing.set_keystone_h(keystone_h); framing.set_flip_h(flip_h); framing.set_flip_v(flip_v); framing.set_modified(modified); // The overlay draws from these, and a rotation re-expresses the rect — // so they have to follow a quarter turn even though no handle moved. window.set_crop_x(crop.x); window.set_crop_y(crop.y); window.set_crop_w(crop.width); window.set_crop_h(crop.height); } /// TRACES: FR-DEV-3 /// Push the chosen crop ratio back to the panel. /// /// The index is written from the choice actually held rather than from what /// was clicked, for the same reason [`sync_framing`] writes the applied crop: /// picking a ratio with no portrait form clears the orientation switch, and a /// panel showing the click rather than the result would light a switch that is /// doing nothing. fn sync_crop_aspect( window: &AppWindow, aspect: &Rc>, portrait: &Rc>, ) { let chosen = aspect.get(); let index = develop::CropAspect::CHOICES .iter() .position(|a| *a == chosen) .unwrap_or(0); let framing = window.global::(); framing.set_aspect(index as i32); framing.set_portrait(portrait.get()); framing.set_aspect_turnable(chosen.has_orientation()); } /// TRACES: FR-DEV-3 /// Reshape the open image's crop onto the chosen ratio, about its centre. /// /// Called when the ratio itself changes, never on opening a photograph: the /// lock outlives the session it is applied to, and reshaping a crop the user /// has not touched — on an image they have only just opened — would edit their /// work as a side effect of stepping through a shoot. A ratio takes effect /// when it is chosen and when a handle is dragged, both of which are things /// the user did on purpose. fn apply_crop_aspect( window: &AppWindow, session: &Rc>>, aspect: &Rc>, portrait: &Rc>, ) { let Some(s) = session.borrow_mut().as_mut().map(|s| { let before = s.framing(); s.set_crop_locked(s.crop(), aspect.get(), portrait.get(), (0.5, 0.5)); // TRACES: FR-DEV-17 // A ratio chosen is a crop committed in one click — there is no drag // to wait for the end of — so it is measured here, as a release is. s.notice_hidden_masks(&before); (s.crop(), s.framing_edits_image()) }) else { return; }; let (crop, modified) = s; window.set_crop_x(crop.x); window.set_crop_y(crop.y); window.set_crop_w(crop.width); window.set_crop_h(crop.height); window.global::().set_modified(modified); } /// TRACES: FR-EXP-6 | FR-EXP-9 | FR-EXP-7 /// Render the open image at full resolution, ready to be handed to the worker. /// /// The render stays here, on the UI thread, and everything after it does not. /// That split is deliberate rather than the remains of the synchronous version /// this replaced: the frame belongs to the `DevelopSession` the interface owns, /// and the edit in it may not have reached a sidecar yet, so a worker that /// re-opened the photograph for itself would export the *saved* version rather /// than the one on screen. /// /// What is left on this thread is a GPU pass and a readback. The Lanczos /// reduction, the encode and the write — the larger half of the wait, and all /// of its variance — leave with the frame. fn render_open_frame( window: &AppWindow, session: &Rc>>, 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()); // TRACES: FR-EXP-8 // The header the session was opened from, cloned because the frame is // about to leave this thread. `None` where the file had none to read, // which the worker takes as "say nothing" rather than as a reason to // invent something. let header = session.source_metadata().cloned(); Ok(export::Source::Rendered { stem, header, frame, }) } /// TRACES: FR-EXP-7 /// Everything a batch needs that only the UI thread can assemble. fn batch_request( sources: Vec, 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(), decoder: dr_decode::default(), } } /// TRACES: FR-EXP-7 | FR-NC-10 /// Send anything waiting in the outbox to the server. /// /// Called after an export and again on every sync pass. Both, deliberately: /// the first is what makes an upload feel immediate, and the second is what /// eventually delivers the exports made while the train was in a tunnel. /// Running it twice over an empty outbox costs a directory listing. fn drain_outbox(library: &Rc) { // 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); // Whether the picker belongs in the group on screen. Asked of the session // rather than decided here: it is a fact about what the operation is // about, and the answer moves if the descriptor ever does. let in_group = session.borrow().as_ref().is_none_or(|s| s.film_in_group()); let can_print = choices .get(selected) .and_then(|(id, _)| *id) .and_then(dr_film::find) .and_then(dr_film::default_print) .is_some(); let adjustments = window.global::(); adjustments.set_film_stocks(slint::ModelRc::new(slint::VecModel::from( choices .into_iter() .map(|(_, name)| slint::SharedString::from(name)) .collect::>(), ))); adjustments.set_film_selected(selected as i32); adjustments.set_film_in_group(in_group); adjustments.set_film_can_print(can_print); adjustments.set_film_print(chosen.map(|(_, print)| print).unwrap_or(false)); } pub(crate) fn sync_rows( window: &AppWindow, rows: &Rc>, 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 .global::() .set_tabs(slint::ModelRc::new(slint::VecModel::from(tabs))); window.global::().set_active_tab(active_tab); let current = match session.borrow().as_ref() { Some(s) => s.rows(), None => Vec::new(), }; // Whether the drawn curve has to be resampled. Sampling runs the spline 96 // times and builds a fresh model, and `sync_rows` is called on *every* // parameter event — so doing it unconditionally spent that on every // exposure or contrast drag, none of which can change the curve's shape. // Only a moved point can, and the in-place update below is what knows. let mut curve_moved = false; if current.len() == rows.row_count() { for (i, mut row) in current.into_iter().enumerate() { let existing = rows.row_data(i); // A curve row carries a *nested* model of point coordinates, and // `rows()` builds a fresh one each call. Swapping it in would // destroy the point elements — including the `TouchArea` holding // the current drag — so the existing model is kept and its values // written through instead. // // It also makes the equality test below meaningful: `ModelRc` // compares by identity, so a brand-new points model would make // every curve row look changed on every event. if let Some(previous) = existing.as_ref() { match update_points_in_place(&previous.points, &row.points) { PointsUpdate::Moved => { curve_moved = true; row.points = previous.points.clone(); } PointsUpdate::Unchanged => row.points = previous.points.clone(), PointsUpdate::Incompatible => {} } } // Only touch rows that actually changed, so unrelated controls // are not needlessly invalidated. if existing.as_ref() != Some(&row) { rows.set_row_data(i, row); } } } else { // A different image, so the control set itself changed. Rebuilding // is correct here — there is no drag to preserve, and the new image's // curve must be drawn whatever shape it is in. rows.set_vec(current); curve_moved = true; // The curves the widget can switch between, named. They can only // change with the operation set, which is what this branch means, so // the walk that derives them is not on the parameter-event path. let channels: Vec = match session.borrow().as_ref() { Some(s) => s.curve_channels().into_iter().map(Into::into).collect(), None => Vec::new(), }; window .global::() .set_curve_channels(slint::ModelRc::new(slint::VecModel::from(channels))); } // Which curve is plotted, on every pass. Picking one that happens to be // shaped like the last — two untouched curves are both the diagonal — // moves no point, so this cannot ride on the resample below: the chips // would go on highlighting the curve the user just navigated away from. let channel = session.borrow().as_ref().map_or(0, |s| s.curve_channel()); window.global::().set_curve_channel(channel); if !curve_moved { return; } let samples = match session.borrow().as_ref() { Some(s) => s.curve_samples(), None => Vec::new(), }; // The drawn curve follows the points. Replacing this model wholesale is // safe where replacing `rows` was not: nothing in it is a drag target. window .global::() .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. /// /// **The same on Android**, where Slint draws with Skia rather than FemtoVG /// but on this device all the same. It used to open a device of its own /// there and read every frame back (TD-1), because wgpu's Vulkan swapchain /// could not pre-rotate and a portrait window tore on the tablet's /// landscape-mounted panel. The patched wgpu-hal and Skia renderer in /// `third_party/` pre-rotate it now; see technical-debt.md TD-1. fn shared_gpu() -> Option { let shared = match pollster::block_on(dr_gpu::GpuContext::new_shared()) { Ok(shared) => shared, Err(e) => { log::warn!("no shareable GPU: {e}"); return None; } }; let dr_gpu::SharedGpu { ctx, instance, adapter, } = shared; // `Manual` is the variant that means "render with these, do not open your // own". The two clones are of wgpu handles, which are refcounts over the // one device and the one queue — not copies of either. let configuration = slint::wgpu_29::WGPUConfiguration::Manual { instance, adapter, device: (*ctx.device).clone(), queue: (*ctx.queue).clone(), }; if let Err(e) = slint::BackendSelector::new() .require_wgpu_29(configuration) .select() { // Dropping the context rather than keeping it: Slint has fallen back // to a renderer that did not adopt our device, so every texture this // context produces is one the compositor cannot sample. A disabled // develop panel is a visible, explicable failure; a texture handed // across devices is undefined behaviour on a good day. log::warn!("Slint would not adopt the GPU device, develop disabled: {e}"); return None; } Some(ctx) } /// TRACES: M-13 | M-14 /// Build and run the viewer. pub fn run(paths: Vec) -> Result<()> { // Mutable because the browsing list has two sources: the command line at // startup, and whatever the library grid is showing when a cell is // clicked. Opening from the grid replaces this so next/previous walk the // library the user is actually looking at rather than the arguments they // launched with. let entries = Rc::new(RefCell::new(collect(&paths))); log::info!("{} image(s) to browse", entries.borrow().len()); // Everything between here and `window.run()` at the bottom happens before // the event loop exists, and on Android that is not merely a slow start: // `android_main` calls this, and nothing drains the activity's input // channel until Slint reaches its first `poll_events` inside `run()`. Five // seconds of it is an ANR, over a window that has never painted. // // So the two phases that touch a driver or a disk are timed, and so is the // whole of it. There is no profiler on the tablet and no way to attach one // to a launch: a line in the log file is the only measurement anybody gets, // and without it the next regression here is guesswork about which of half // a dozen candidates it was. let launch_began = std::time::Instant::now(); // Before the window, and it has to be: this selects the Slint backend, and // creating a window selects one for us. See `shared_gpu`. The device is // shared by demosaic, the adjust pass and the compositor; without one the // app still browses through the preview path, just without develop. // // Timed rather than moved: this is a Vulkan instance, an adapter // enumeration and a device request, none of which can be deferred // because the backend must be chosen before a window exists. let gpu = shared_gpu(); log::info!("gpu opened in {} ms", launch_began.elapsed().as_millis()); let window = init_window(&gpu)?; wire_inference_status(&window); wire_diagnostics(&window, &gpu); // Every background job reports here, and this draws the bar across the top // of the shell and fills the settings page's list. Built before the // controllers because they take a handle to it: a job that starts during // startup — the scan a resumed session begins immediately — has to have // somewhere to report to before it starts, or its first minute is invisible. let activity = activity::ActivityLog::new(); activity.attach(&window); { let activity = activity.clone(); window.on_activity_clear_finished(move || activity.clear_finished()); } // Set once `show` exists; see where the library grid is wired below. #[allow(clippy::type_complexity)] let open_from_library: Rc>>> = 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); let (library, prefetch, collections, identity, settings) = construct_screens( &window, &paths, &activity, &gpu, &open_from_library, &leave_develop, ); // Held for the life of the window only to keep the People screen's cover // cache registered with the thumbnail tier above — nothing here reads it // again before `window.run()`. let _identity = identity; wire_import_and_merge(&window, &activity, &gpu, &library, &collections, &settings); wire_settings_screen(&window, &settings, &library); window.set_total(entries.borrow().len() as i32); let index = Rc::new(RefCell::new(0usize)); // The current develop session, if the file yielded sensor data. let session: Rc>> = 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.global::().set_rows(rows.clone().into()); // Viewport size, tracked so a re-render after a slider move matches it. // // In *physical* pixels, which is what the render target wants and what // FR-DSP-8 means by handling fractional scaling without resampling. The // one caller that thinks in logical pixels is Slint's resize callback, // and it converts on the way in. let viewport = Rc::new(RefCell::new((1024u32, 768u32))); // TRACES: FR-DSP-8 // What the displays are, and which one the canvas is on. Probed once here // so that the About page can describe the session's colour path before any // photograph is opened — the acquisition path is a property of the desktop // and not of the image. let display = display_ui::DisplayWatch::probe(); // TRACES: FR-CULL-3 // How the photographer wants focus peaking drawn, or `None` for off. // // **Held here rather than on the session, which is the opposite of where // every edit lives.** A session is one photograph; peaking is a way of // *looking* at a folder of them. Someone culling three thousand frames // switches it on once, and a flag that reset with the session would ask // them to switch it on three thousand times — which is why // `reset_view_state` deliberately leaves it alone while emptying the // histogram beside it. let chosen_peaking: Rc>> = Rc::new(Cell::new(None)); // TRACES: FR-UI-4 // The point being inspected at 1:1, in fractions of the framed image, or // `None` where the view is fitted. // // **Held here rather than on the session, on exactly the argument // `chosen_peaking` above makes.** A session is one photograph and this is // a way of looking at a folder of them: checking the same eye across forty // portraits is the entire reason a 1:1 view is worth reaching, and a // magnification that reset with the session would ask for the same zoom // and the same pan forty times. So `reset_view_state` empties the // histogram and leaves this alone, and the two open paths below put the // next photograph under the magnifier the last one was left under. // // It is a *viewing* state throughout. It never reaches the graph's crop, // the sidecar or an export — `Framing::view` is kept out of all three, and // `render_the_file` suspends it — so a photographer left at 4× exports the // whole frame, as they did before this existed. let inspection: Rc> = Rc::new(Cell::new(Inspection::default())); let render_now = build_render_now(&session, &viewport, &display, &chosen_peaking); let redraw = build_redraw(&render_now); let show = build_show( &entries, &index, &session, &redraw, &gpu, &rows, &open_image, &library, &viewport, &inspection, ); // Now `show` exists, close the knot left open at the library wiring. wire_remote_open( &window, &open_from_library, &library, &session, &redraw, &rows, &gpu, &activity, &open_image, &viewport, &inspection, &prefetch, ); develop_ui::wire( &window, &session, &rows, &redraw, &render_now, &show, &entries, &index, &viewport, &inspection, &chosen_peaking, &settings, &library, &collections, &activity, &gpu, &clipboard, &open_image, &leave_develop, ); wire_shell(&window, &viewport, &redraw, &display); if !entries.borrow().is_empty() { show(&window); } // The figure that matters, because it is the one the input dispatcher is // counting against. Anything approaching five seconds here is an ANR on the // next Android launch whatever the phases above say, and anything that // pushes it up belongs on a worker. log::info!( "startup: {} ms to the event loop", launch_began.elapsed().as_millis() ); #[cfg(all(feature = "automation", unix))] automation::attach(&window); window.run()?; Ok(()) } /// Build the window and set what has to be true before it is shown: the /// window-manager app id, the About page's version, and the backend line /// that names the adapter (or says there is none). fn init_window(gpu: &Option) -> Result { let gpu = gpu.clone(); let before_window = std::time::Instant::now(); let window = AppWindow::new()?; log::info!("window built in {} ms", before_window.elapsed().as_millis()); // TRACES: FR-PLAT-LIN-1 // The name the compositor knows this window by, and the reason the // launcher shows a real icon rather than a grey square. // // `app.slint` sets `icon:`, and that is genuinely embedded (see // `build.rs`) — but a Wayland compositor ignores a client-set icon // entirely. It takes the icon from the `.desktop` file whose basename // matches the surface's `app_id`, and nothing else. So the embedded icon // is what X11 and the window itself use, and *this* is what GNOME's // overview, dash and alt-tab use. Both are needed and neither substitutes // for the other. // // The string must equal the installed `.desktop` file's basename exactly: // `packaging/paris.tourolle.darkroom.desktop`. It matches the Android // package id (`AndroidManifest.xml`) on purpose — one application, one // reverse-DNS name on both platforms. // // **After the window is constructed, and before it is shown.** This used // to sit at the top of `run`, on the reasoning that the app id is read // when the surface is created — which is true, and still left it never // set. `slint::set_xdg_app_id` goes through the global context, and there // is no global context until something installs a Slint platform: either // `BackendSelector` in `shared_gpu`, or `AppWindow::new` falling back to // the default. Called before both, it returned `NoPlatform` and did // nothing, and GNOME showed a generic tile for the window from the day // the call was written. Constructing a window is not showing it — `run` // is far below — so this is both late enough to have a platform and early // enough to be read. if let Err(e) = slint::set_xdg_app_id("paris.tourolle.darkroom") { // Not fatal, but not silent either: the cost is the launcher and the // task bar showing a grey square for a window that is working // perfectly, which is invisible from inside the application and was // missed for exactly that reason. X11 and Android reach here too, // where the call is a no-op and the message is merely noise at debug. log::warn!("could not set the xdg app id, so the launcher icon will be generic: {e}"); } // The version the About page shows. Taken from the crate rather than // passed in, so it is the version of the code that is running and cannot // be set to something else by a caller. window.set_app_version(env!("CARGO_PKG_VERSION").into()); match &gpu { Some(ctx) => { log::info!("adapter: {} ({:?})", ctx.adapter_name(), ctx.backend()); window.set_adapter(ctx.adapter_name().into()); window.set_backend(format!("{:?}", ctx.backend()).to_uppercase().into()); } None => window.set_backend("NO GPU".into()), } Ok(window) } /// TRACES: FR-INF-1 /// What the models run on. Re-read every two seconds because the answer /// changes twice after launch — when the probe reports and as each /// engine lands — and the page is open for longer than either takes. fn wire_inference_status(window: &AppWindow) { let set = |w: &AppWindow| { let (line, detail) = inference::about_lines(); w.set_inference_backend(line.into()); w.set_inference_detail(detail.into()); }; set(window); let weak = window.as_weak(); let timer = Rc::new(slint::Timer::default()); let held = timer.clone(); timer.start( slint::TimerMode::Repeated, std::time::Duration::from_secs(2), move || { let _keep = &held; if let Some(w) = weak.upgrade() { set(&w); } }, ); } /// TRACES: NFR-OPS-1 /// The diagnostics bundle, wired as the two presses the requirement /// describes. Preparing gathers the log and the crash records into memory /// and shows what would be written; saving writes it; discarding drops it /// and writes nothing. The gathered bundle is held between the presses so /// that what is saved is exactly what was shown — a bundle gathered again /// at save time could differ from its own preview by whatever was logged /// while the user was reading. fn wire_diagnostics(window: &AppWindow, gpu: &Option) { let gpu = gpu.clone(); use dr_plat::diagnostics::bundle::{Bundle, Facts}; let facts = Facts { app_version: env!("CARGO_PKG_VERSION").to_string(), schema_version: dr_catalog::schema::SCHEMA_VERSION, graphics: match &gpu { Some(ctx) => format!( "{} ({:?}), {}", ctx.adapter_name(), ctx.backend(), ctx.driver() ), None => "no GPU".to_string(), }, }; let pending: Rc>> = Rc::new(RefCell::new(None)); { let weak = window.as_weak(); let pending = pending.clone(); window.on_diagnostics_prepare(move || { let Some(w) = weak.upgrade() else { return }; let bundle = Bundle::gather(&facts); w.set_diagnostics_preview( bundle .preview(&dr_plat::diagnostics::bundle::default_dir()) .into(), ); w.set_diagnostics_result("".into()); *pending.borrow_mut() = Some(bundle); }); } { let weak = window.as_weak(); let pending = pending.clone(); window.on_diagnostics_save(move || { let Some(w) = weak.upgrade() else { return }; let Some(bundle) = pending.borrow_mut().take() else { return; }; let dir = dr_plat::diagnostics::bundle::default_dir(); let result = match bundle.write_to(&dir) { Ok(path) => { log::info!("diagnostics bundle written: {}", path.display()); format!("Saved as {}", path.display()) } Err(e) => { log::warn!("diagnostics bundle not written: {e}"); format!("Could not save the bundle to {}: {e}", dir.display()) } }; w.set_diagnostics_preview("".into()); w.set_diagnostics_result(result.into()); }); } { let weak = window.as_weak(); window.on_diagnostics_discard(move || { let Some(w) = weak.upgrade() else { return }; *pending.borrow_mut() = None; w.set_diagnostics_preview("".into()); w.set_diagnostics_result("".into()); }); } } /// Construct the library grid, the collections sidebar, the People screen /// and the settings controller, and show whichever of launch, the local /// files on the command line, or a remembered library the startup state /// calls for. /// /// Returns the controllers later sections need — `settings`, `library` and /// `collections` for import, merge, export and presets — plus `identity` and /// `prefetch`, kept alive by the caller for the life of the window. #[allow(clippy::too_many_arguments, clippy::type_complexity)] fn construct_screens( window: &AppWindow, paths: &[PathBuf], activity: &Rc, gpu: &Option, open_from_library: &Rc>>>, leave_develop: &Rc>>>, ) -> ( Rc, Rc, Rc, Rc, Rc, ) { let activity = activity.clone(); let gpu = gpu.clone(); let open_from_library = open_from_library.clone(); let leave_develop = leave_develop.clone(); // The library grid: scan the remote tree into the catalog, then show what // was found. Clicking a cell opens it in develop. // // Declared out here rather than inside the launch block below because the // develop side reads `paths` to rebuild its browsing list when an image is // opened from the grid. let library = library_ui::LibraryController::new(activity.clone()); // TRACES: FR-NC-6a | FR-UI-4 // The photographs either side of the open one, fetched into the cache // while it is being looked at, so a step along the roll is a disk read. // Fed from the develop open below; its transfers are shown here. let prefetch = Rc::new(library::Prefetcher::new()); show_prefetches(&prefetch, &activity); // Hoisted out of the launch block below because the settings clipboard // needs it too: a paste onto "the selection" reads the selection from // here, and that wiring happens once the develop session exists. let collections = collections_ui::CollectionsController::new(activity.clone()); // The People screen's own state: which person is open, and which of their // faces are ticked for a split. let identity = std::rc::Rc::new(identity_ui::IdentityController::new()); // Settings: cache ceilings, export defaults and the face grouping dials, in // their own config file. // // Wired independently of every view below. It reads no library and holds no // session, so it has nothing to be sequenced against — which is the reason // it is a page reachable from anywhere rather than a panel inside one view. // Hoisted this far up because three separate things need the same record: // the export action, the importer, and the People screen's grouping dials. // A controller scoped to any one wiring block would be gone by the time the // others were built, and two controllers each holding their own copy would // each save over the other. let settings = settings_ui::SettingsController::new(); // TRACES: FR-PLAT-AND-5 // The thumbnail tier. Registered here, beside the thing it frees, so that // a controller which grows another cache is one line from offering it up. // // Weak, not strong: `run` returns when the window closes, and a registry // holding the last reference to a controller would keep it — and every // decoded portrait in it — alive past the interface it belonged to. { let identity = std::rc::Rc::downgrade(&identity); memory::evict_at(memory::Tier::Thumbnails, move || { if let Some(ctl) = identity.upgrade() { ctl.clear_covers(); } }); } // Launch screen: shown when there is nothing to display — no local paths // and no configured library. A user who has already signed in and chosen // a folder goes straight to their images (FR-NC-1). { let controller = launch_ui::LaunchController::new(); let startup = controller.model.borrow().startup_action(!paths.is_empty()); // `OpenLibrary` is overwritten moments later by `library_ui::open` // below, once a stored session exists; `ShowLocalFiles` leaves // `develop` as it found it, since there is no library to switch to. window.set_active_view(match startup { launch::Startup::ShowLaunchScreen => View::Launch, launch::Startup::ShowLocalFiles | launch::Startup::OpenLibrary => View::Develop, }); let library = library.clone(); let collections = collections.clone(); // The click handler needs `show`, which is built further down because // it captures the develop session and the GPU context. This cell is // the knot between them: wired empty here, filled once `show` exists. // A click before then is a no-op rather than a panic — the grid cannot // be reached until the window is running, by which point it is set. let open_from_library = open_from_library.clone(); let leave_develop = leave_develop.clone(); library_ui::wire( window, library.clone(), collections.clone(), move |path| { let Some(f) = open_from_library.borrow().clone() else { log::warn!("open requested before the viewer was ready: {path}"); return; }; f(path); }, Rc::new(move || { if let Some(f) = leave_develop.borrow().clone() { f(); } }), ); // The collections sidebar shares the library's catalog handle rather // than opening its own: one SQLite connection, so an edit here is // visible to the grid's next read without a reopen. // // The reload closure is the seam between the two controllers. The // sidebar decides *what* is scoped; the library owns the window, the // offset and the thumbnail workers, so it is what actually reloads — // and it must be told the scope before it reads, which is why both // happen here in one place rather than each controller reaching for the // other. { let weak = window.as_weak(); let lib = library.clone(); let coll = collections.clone(); let lib_ids = library.clone(); let lib_span = library.clone(); let lib_session = library.clone(); collections_ui::wire( window, collections.clone(), library.catalog(), move || { let Some(w) = weak.upgrade() else { return }; // Order matters: `set_scope` clears the trash flag, because // picking a collection is how you leave the trash. Setting // the flag second is what lets selecting the trash itself // survive the call. lib.set_scope(coll.scope()); lib.set_viewing_trash(coll.viewing_trash()); library_ui::reload(&w, &lib); }, move || lib_ids.visible_ids(), // What a shift-click selects: the whole run between its two // ends, read from the catalog rather than from the hundred or // so rows that happen to be loaded. move |first, last| lib_span.ids_in_span(first, last), // The trash's MOVE and DELETE go to the same account the scan // and thumbnail workers use. move || lib_session.session(), ); // The People screen reads the same catalog and the same thumbnail // store the grid does — its face crops come from the proxies the // grid already built, which is the whole reason face indexing is // affordable (FR-CULL-8). let lib_store = library.clone(); identity_ui::wire( window, identity.clone(), library.catalog(), activity.clone(), settings.clone(), move || { let conn = lib_store.session()?; dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account)) .ok() .map(std::rc::Rc::new) }, // The weights are not shipped and are not a build input // (docs/dev/faces.md §2): the user puts them beside the catalog, // and their absence is the ordinary state of a fresh install. { let lib = library.clone(); let settings = settings.clone(); move || { let conn = lib.session()?; library::face_models(&conn.account, settings.snapshot().faces.detector) } }, // The sweep opens its own connection on its own thread, so it // takes paths rather than the handles this screen holds — and // a connection, because it fetches the pixels it indexes rather // than reading whatever the grid happened to leave behind. { let lib = library.clone(); move || { let conn = lib.session()?; let catalog = library::catalog_path(&conn.account); let thumbs = library::thumbs_dir(&conn.account); Some((conn, catalog, thumbs)) } }, // TRACES: FR-CULL-8 // The same device the develop view draws with. Face indexing // renders natively through FR-EXP-9's path, so it needs a GPU // for the same reason export does. gpu.clone(), ); } let weak = window.as_weak(); let store_ctl = controller.clone(); let lib = library.clone(); let coll = collections.clone(); launch_ui::wire(window, controller.clone(), move |session| { let Some(w) = weak.upgrade() else { return }; log::info!("opening library for {}", session.describe()); library_ui::open(&w, lib.clone(), coll.clone(), &store_ctl.store, session); }); match startup { launch::Startup::ShowLaunchScreen => { log::info!("no library configured — showing the launch screen"); } launch::Startup::ShowLocalFiles => { log::info!("{} file(s) named on the command line", paths.len()); } // Skipping the launch screen must not mean skipping the library: // the "Open library" button lives on the screen we just bypassed, // so nothing else would ever start the scan. launch::Startup::OpenLibrary => { let session = controller.model.borrow().session().cloned(); if let Some(session) = session { log::info!("resuming library for {}", session.describe()); // Before anything opens a store: an upgrade must not // abandon a catalog, its thumbnails, or the offline // ratings and edits waiting beside them. library::migrate_legacy_cache_data(&session); library_ui::open( window, library.clone(), collections.clone(), &controller.store, session, ); } } } } (library, prefetch, collections, identity, settings) } /// TRACES: FR-CAT-10 | FR-CAT-11 | FR-NC-7a | FR-NC-7b /// Import: a card into the library, and on to the server. /// /// Wired after the settings controller exists because it shares it — an /// import's options are stored in the same file as everything else, and two /// controllers each holding their own copy would each save over the other. /// /// The account is fetched afresh inside the closure rather than captured: /// this runs once at startup, where a library can be opened, closed and /// re-opened for the whole life of the window. fn wire_import_and_merge( window: &AppWindow, activity: &Rc, gpu: &Option, library: &Rc, collections: &Rc, settings: &Rc, ) { let activity = activity.clone(); let gpu = gpu.clone(); let library = library.clone(); let collections = collections.clone(); let settings = settings.clone(); // Whether the header offers Import at all. A fact about the platform, // set once: it cannot change while the window is open, and on Android // it is false because there is no card to reach and nothing to write // through (`dr_plat::imports_supported`). window.set_import_supported(dr_plat::imports_supported()); // TRACES: FR-MRG-1 { let merge = merge_ui::MergeController::new(activity.clone()); let library_for_sources = library.clone(); let collections_for_sources = collections.clone(); let library_for_context = library.clone(); let library_for_done = library.clone(); merge_ui::wire( window, merge, gpu.clone(), move || library_for_sources.export_sources(&collections_for_sources.selected()), move || { let conn = library_for_context.session()?; Some(merge_ui::Context { outbox: export::outbox_dir(&conn.account), conn, }) }, move |w| { // Staged beside its sources: upload it now rather than // on the next sync pass, then look for it, exactly as an // import does. drain_outbox(&library_for_done); w.global::().invoke_library_rescan(); }, ); } let import = import_ui::ImportController::new(settings.clone(), activity.clone()); let library_for_context = library.clone(); let weak = window.as_weak(); import_ui::wire( window, import, move || { let conn = library_for_context.session()?; Some(import_ui::Context { catalog: library::catalog_path(&conn.account), library_label: conn.account.root.clone(), // The same formats the scan looks for. An import that took // types the library then ignores would copy files off the // card that never appear in the grid. filter: conn.account.format_filter(), upload: Some(import::Upload { library: conn.account.root.clone(), // The same shard store the grid reads and the sync // pushes, so a thumbnail made during an import is the // one every other client gets. thumbs: library::thumbs_dir(&conn.account), staging: import::staging_dir(&conn.account), conn, }), }) }, move || { // The import wrote files into a folder on this machine and, if // the account allowed it, into the library on the server. Only // the second is what the grid shows, so this asks for the scan // that finds them rather than inserting rows itself. if let Some(w) = weak.upgrade() { w.global::().invoke_library_rescan(); } }, ); } /// The settings page: cache usage, the export folder picker, and the /// callbacks a saved change has to ripple into the library controller. fn wire_settings_screen( window: &AppWindow, settings: &Rc, library: &Rc, ) { let settings = settings.clone(); let library = library.clone(); // What the cache actually holds, so the ceiling above it is a figure // the user can judge rather than an abstract one. // // Empty at this point on a launch and that is expected: the catalog it // queries is being opened on a worker (`library_ui::open_catalog_soon`) // and is not there yet. The figure is filled in when the settings page // is opened, which is the only time it is looked at — see the `on_open` // closure passed to `settings_ui::wire`. settings.set_usage_label(describe_cache_usage(&library)); // Rendered once up front so the page is correct the first time it is // opened, rather than on the second open after a callback has run. settings_ui::render(window, &settings); // The export button carries its destination, so it has to be correct // before the first click rather than after the first settings edit. refresh_export_label(window, &settings); // Apply what is on disk before anything can use it. Without this the // controller's defaults stand until the user happens to open the // settings page and change something — so a cache deliberately capped // at 2 GB last session would spend this one filling to the default. { let stored = settings.snapshot(); library.set_cache_budget(stored.cache.original_budget_bytes); library.set_keep_opened_originals(stored.cache.keep_opened_originals); library.set_fetch_ahead(stored.cache.fetch_ahead); library.set_write_xmp_sidecars(stored.library.write_xmp_sidecars); library.set_timeline_bars(stored.library.timeline_bars); library.set_face_model_id(inference::model_id(stored.faces.detector)); } // --- the export folder picker ---------------------------------------- // // Wired here rather than inside `settings_ui::wire` because listing a // remote folder needs credentials, and the settings page deliberately // holds no session — it is reachable before a library is opened and must // not depend on one existing. { let weak = window.as_weak(); let ctl = settings.clone(); let library = library.clone(); // Every entry point needs the same three things, so they are fetched // once here rather than at four call sites. let start = move |ctl: &Rc, 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 .global::() .on_browse_open_picker(move || { let Some(w) = weak.upgrade() else { return }; // Opens on the library root rather than on whatever the // destination field happens to contain: a half-typed path // would list nothing and look like a broken picker. ctl.browser.replace(Some(launch::FolderBrowser { path: String::new(), entries: Vec::new(), loading: true, })); start(&ctl, &weak, &library, String::new()); settings_ui::render(&w, &ctl); }); } { let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start); window .global::() .on_browse_into(move |name| { let Some(w) = weak.upgrade() else { return }; let path = { let mut browser = ctl.browser.borrow_mut(); let Some(b) = browser.as_mut() else { return }; let path = b.child_path(&name); b.path = path.clone(); b.entries.clear(); b.loading = true; path }; start(&ctl, &weak, &library, path); settings_ui::render(&w, &ctl); }); } { let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start); window.global::().on_browse_up(move || { let Some(w) = weak.upgrade() else { return }; let path = { let mut browser = ctl.browser.borrow_mut(); let Some(b) = browser.as_mut() else { return }; let Some(path) = b.parent_path() else { return }; b.path = path.clone(); b.entries.clear(); b.loading = true; path }; start(&ctl, &weak, &library, path); settings_ui::render(&w, &ctl); }); } { let (weak, ctl) = (weak.clone(), ctl.clone()); window.global::().on_browse_confirm(move || { let Some(w) = weak.upgrade() else { return }; // The folder being *shown* is the one chosen, matching the // library picker — so "use this one" means the same thing in // both places rather than depending on a selection the list // does not have. let chosen = ctl.browser.borrow().as_ref().map(|b| b.path.clone()); if let Some(path) = chosen { ctl.set_destination(path); } ctl.browser.replace(None); settings_ui::render(&w, &ctl); refresh_export_label(&w, &ctl); }); } { let (weak, ctl) = (weak.clone(), ctl.clone()); window.global::().on_browse_cancel(move || { let Some(w) = weak.upgrade() else { return }; ctl.browser.replace(None); settings_ui::render(&w, &ctl); }); } } let lib = library.clone(); let ctl = settings.clone(); let weak = window.as_weak(); settings_ui::wire( window, settings.clone(), move |s| { // A ceiling that moved has to be applied to what is already on // disk, or lowering it would only affect future downloads and the // cache would sit over budget indefinitely. lib.set_cache_budget(s.cache.original_budget_bytes); lib.set_keep_opened_originals(s.cache.keep_opened_originals); lib.set_fetch_ahead(s.cache.fetch_ahead); lib.set_write_xmp_sidecars(s.library.write_xmp_sidecars); if let Some(w) = weak.upgrade() { refresh_export_label(&w, &ctl); } // The axis is cut into bars when the window is loaded, so a new // count only shows once something reloads it. Done here, and only // when the figure actually moved: a setting that appears to do // nothing until the user scrolls is one they will change twice and // then leave wrong. if lib.set_timeline_bars(s.library.timeline_bars) { if let Some(w) = weak.upgrade() { library_ui::reload(&w, &lib); } } // The pipeline the next sync and the next sweep run under, and // the two lines on the page that describe it: which detector // file is installed, and how much of the library that // pipeline has covered — which for a freshly chosen one is // nothing, and saying so is the point. lib.set_face_model_id(inference::model_id(s.faces.detector)); if let Some(w) = weak.upgrade() { refresh_face_status(&w, &lib, s.faces.detector); } // Lowering the ceiling evicts, so the figure beside it has just // changed — leaving the old one would show the cache still over a // limit that was enforced a moment ago. ctl.set_usage_label(describe_cache_usage(&lib)); if let Some(w) = weak.upgrade() { settings_ui::render(&w, &ctl); } }, { // Face coverage, read when the page opens. The figures live in the // catalog and the settings page holds no session, so they arrive // through here rather than being kept up to date continuously — // they are only ever looked at while this page is on screen. let lib = library.clone(); let ctl = settings.clone(); move |w: &AppWindow| { refresh_face_status(w, &lib, ctl.snapshot().faces.detector); // Here rather than at startup, on exactly the reasoning // above: the catalog this reads is opened on a worker now, // so a launch has nothing to describe, and the figure is // only ever read while this page is on screen. `render` // again because `settings_ui::wire` renders *before* it // calls this, and the label is one of the things it draws. ctl.set_usage_label(describe_cache_usage(&lib)); settings_ui::render(w, &ctl); } }, ); } /// Build the render closure: re-render the current session into the canvas. /// /// Called on every slider change, so it must do no more than run the /// adjust pass — the demosaic is not repeated. fn build_render_now( session: &Rc>>, viewport: &Rc>, display: &Rc, chosen_peaking: &Rc>>, ) -> Render { let session = session.clone(); let viewport = viewport.clone(); let display = display.clone(); let chosen_peaking = chosen_peaking.clone(); // TRACES: FR-DEV-5 // The history revision the panel was last built from. See the use below: // the list is rebuilt when it would read differently, not when the picture // is redrawn, and those are very different rates. let drawn_history: Rc>> = Rc::new(Cell::new(None)); // TRACES: FR-DEV-5 // And the snapshot list, compared by value rather than by a revision: it // is a handful of rows, and a held comparison changes one of them // without a step being taken. let drawn_snapshots: Rc>>> = Rc::new(RefCell::new(None)); { let session = session.clone(); let viewport = viewport.clone(); let drawn_history = drawn_history.clone(); let drawn_snapshots = drawn_snapshots.clone(); let display = display.clone(); let chosen_peaking = chosen_peaking.clone(); Rc::new(move |window: &AppWindow, draft: bool| { let mut slot = session.borrow_mut(); let Some(s) = slot.as_mut() else { return }; // TRACES: FR-DSP-8 | FR-DSP-6 // **Every canvas render is encoded for the display showing it.** // // Set here rather than pushed from the display watch, and rather // than set once when a photograph is opened, because this is the // one path every frame takes. A session opened while the window // sits on the second monitor is then correct on its *first* frame // — where a push would leave it sRGB until the next poll, which is // a visible flash of the wrong colour on every image opened. // // Free when nothing has changed: the field is compared before it // is written, and an unchanged output space composes to the same // structure hash and the same cached pipeline. s.set_display_space(display.space()); // TRACES: FR-DEV-5 // Whether undo has anywhere to go, pushed from here because every // edit ends in a redraw and nothing else is on all of their paths: // the parameter callbacks sync rows, the framing ones sync the // geometry panel, and a paste arrives through neither. let steps = window.global::(); steps.set_can_undo(s.can_undo()); steps.set_can_redo(s.can_redo()); // TRACES: FR-DEV-5 | FR-DEV-7 // The list, on the same path and for the same reason — but only // when it would read differently. A drag ends in a redraw per // frame while folding into one step, so an unconditional rebuild // here would tear down and recreate every row of the panel sixty // times a second to arrive back at the list already on screen. let revision = s.history_revision(); if drawn_history.get() != Some(revision) { drawn_history.set(Some(revision)); steps.set_rows(slint::ModelRc::new(slint::VecModel::from(s.history_rows()))); steps.set_undo_label(s.undo_label().into()); } // TRACES: FR-DEV-17 // The notice about a crop that stranded a mask, on the path every // history move takes, so an undo that takes the crop back takes // the notice with it in the same frame. develop_ui::sync_crop_notice(window, s); // TRACES: FR-DEV-5 // The snapshots, on the same path. Rebuilt only when the list // would read differently, for the reason the steps are: a held // comparison redraws, and it is the one thing here that changes // a row without changing the history. let snapshots = s.snapshot_rows(); if drawn_snapshots.borrow().as_ref() != Some(&snapshots) { *drawn_snapshots.borrow_mut() = Some(snapshots.clone()); steps.set_snapshots(slint::ModelRc::new(slint::VecModel::from(snapshots))); } // TRACES: FR-DEV-3 // Which part of the region overlay the view is showing. Here // rather than in the panel's own sync because a pan or a zoom // changes it while changing no mask and no row — and every one of // those ends in a redraw. masks_ui::sync_overlay_view(window, s); // TRACES: FR-DEV-8 // And where the repairs are drawn, for the same reason: a pan or a // zoom moves every circle while touching no repair, and a circle // left where the mark used to be is worse than no circle at all. spots_ui::sync_handles(window, s); // The column's own numbers, pushed from here as well so that a // photograph opened with repairs already on it arrives with the // panel describing them — the sidecar lands after the callbacks // have all been installed, and a redraw is the one path every // arrival takes. spots_ui::sync_panel(window, s); // TRACES: FR-CULL-3 // The session owns the pass and the interface owns the choice, so // they are joined here — on the one path every frame takes, which // is also what makes a photograph opened with peaking already on // arrive with its marks rather than without them. if s.peaking() != chosen_peaking.get() { s.set_peaking(chosen_peaking.get()); } window .global::() .set_available(s.peaking_available()); let (mut w, mut h) = *viewport.borrow(); // **Half resolution while the gesture is still moving.** // // The adjust pass scales with pixel count, so halving each edge is // roughly a quarter of the work — the difference between keeping // up with a drag and lagging behind it. Less dramatic since S1 // removed the readback that scaled the same way and cost far more, // but a dispatch is still not free at 4K. // A draft frame is visible for one gesture and is replaced by a // full-resolution one the moment motion stops, so the cost is a // little softness exactly while the image is moving too fast to // study anyway. if draft { w = (w / 2).max(1); h = (h / 2).max(1); } // TRACES: FR-DEV-7 // Three renders of one graph, chosen here because this is the one // path every frame takes — so a comparison held while a slider is // still settling shows the original at full resolution too, rather // than reverting the moment anything else asks for a redraw. // // Crop mode shows the whole frame, or the area being cropped away // would not be on screen for the handles to drag across. The // overlay draws the rect on top of it. let rendered = if window.global::().get_view_mode() == ViewMode::Crop { s.render_uncropped(w, h).map(|(image, _, _)| image) } else if window.get_showing_original() { s.render_original(w, h) } else if s.compared_snapshot().is_some() { // TRACES: FR-DEV-7 // The same hold, against a snapshot rather than the file. s.render_compared(w, h) } else { s.render(w, h) }; match rendered { Ok(image) => { // TRACES: FR-DSP-4 // **The draft flag goes past the render**, to the two // places that have to know which kind of frame is up: the // canvas, which crossfades a draft into the sharp frame // that settles it, and the histogram, which is measured // on settled frames only and so describes an older frame // while a draft is showing. // // The draft is kept as the fade's starting picture. That // is a refcount on the texture it was drawn into, not a // copy or a render — see `canvas-previous` for why the // sharp frame cannot overwrite it. if draft { window.set_canvas_previous(image.clone()); } window.set_canvas(image); window.set_canvas_draft(draft); window.global::().set_provisional(draft); window.set_load_error("".into()); // The readout and the "Fit" button follow the session // rather than the gesture, so a clamped zoom shows the // value that was actually applied. window.set_zoom(s.zoom()); window.set_zoomed(s.is_zoomed()); // Filtering follows the magnification, measured against the // *full* viewport rather than `w`/`h`: a draft frame is // rendered at half resolution, and letting that flip the // canvas to smooth would make it change character for the // duration of every gesture. let (vw, vh) = *viewport.borrow(); window.set_magnified(s.magnifies_source(vw, vh)); // TRACES: FR-DSP-7 // **Counted on the settled frame and no other.** // // FR-DSP-7 requires the histogram not extend the FR-DSP-3 // frame budget, and `draft` is already exactly the flag // that says a gesture is still moving — so this reduction // and its 4 KB transfer happen once when the slider stops // rather than on every one of the forty frames a drag // emits. Nothing is lost by it: a histogram of a // half-resolution frame flickering past under a finger is // not a reading anyone takes. if !draft { window.global::().set_data( s.histogram() .as_ref() .map_or_else(histogram::empty, histogram::view), ); // **The raw reading, pushed on the same path but not // recomputed on it.** `DevelopSession::raw_histogram` // caches: the demosaiced source is fixed for the life // of the session, so this costs one dispatch per // photograph and a clone of four kilobytes per settled // frame thereafter. It is pushed from here anyway // rather than once at open, because this is the one // path every photograph takes and a panel that had to // be told separately would be empty on some route into // develop mode. // // Three outcomes, said apart. A file that was never // raw has no white level and so no scale to measure // headroom against; a device that could not build the // reduction has a fault worth reporting; and only the // third is a reading. let raw = if !s.has_sensor_data() { histogram::raw_empty(histogram::RawAbsence::NotRaw) } else { s.raw_histogram().as_ref().map_or_else( || histogram::raw_empty(histogram::RawAbsence::NoDevice), histogram::raw_view, ) }; window.global::().set_raw_data(raw); } // TRACES: FR-CULL-3 | NFR-P14 // **Marked on the settled frame and no other**, and unlike // the histogram beside it the marks are taken *down* in // between rather than left standing. // // The reason is not budget — the dispatch is a fraction of // a millisecond and would fit inside a draft frame // comfortably. It is that peaking measures the top octave // of the frame it is given, and a draft frame is rendered // at half resolution: a defocused edge that spans four // pixels there spans two, which is the signature of a // sharp one. Measuring it would mark the out-of-focus // background of every photograph, briefly, during every // drag. A stale overlay is no better, because a pan moves // the picture out from under it. // // So the marks pause while a control is moving and return // when it stops, which the panel says out loud rather than // leaving to be discovered. let overlay = (!draft).then(|| s.focus_overlay()).flatten(); match overlay { Some(image) => { window.set_focus_overlay(image); window.set_focus_overlay_ready(true); } None => window.set_focus_overlay_ready(false), } } Err(e) => { log::warn!("render failed: {e}"); window.set_load_error(e.into()); window.set_canvas_draft(false); window.global::().set_provisional(false); // No frame, so nothing to describe. The stale plot would // otherwise sit beside the error message looking current. window.global::().set_data(histogram::empty()); // **The raw reading is left standing, and that is not an // oversight.** It describes the sensor data behind the // session, which a failed *render* says nothing about: the // demosaic succeeded or there would be no session at all. // Emptying it here would take away the one instrument // still telling the truth, at the moment the other one // stopped. // TRACES: FR-CULL-3 // And nothing to mark. Focus marks over the last frame // that rendered, beside a message saying this one did not, // is the same confident lie in a second instrument. window.set_focus_overlay_ready(false); } } }) } } /// Post exactly one render onto the event loop per burst of requests. /// /// **Rendering is decoupled from input, and this is why.** /// /// A render used to be a blocking GPU round-trip — S1 removed the block, /// but not the reason for this, so read it as history that still applies. /// Running one straight from a `moved` handler put that stall *inside* the /// gesture: touch events arrive far faster than a render completes, so the /// input queue backed up, positions arrived stale, and Android — seeing the /// events go unconsumed — reclaimed the gesture and delivered `cancel` /// instead of `up`. That is the dropped-drag bug, and no amount of tuning /// inside the Slint handlers fixes it while the stall is on the input path. /// /// So `redraw` no longer renders. It marks the canvas dirty and posts a /// single render onto the event loop; every further request while one is /// already pending just sets the flag again. A drag emitting forty events /// therefore renders a handful of times instead of forty, and — the part /// that actually fixes the drop — each event handler returns immediately, /// so the gesture is always consumed promptly. /// /// The flag is re-checked *after* the render because parameters may have /// moved again while it ran; that repost is what keeps the image converging /// on the finger rather than settling on a stale frame. fn build_redraw(render_now: &Render) -> Rc { let render_now = render_now.clone(); // Which frame to draw and when to settle, decided apart from the timers // that carry it out — see `refine` for the rules and their tests. let refine = Rc::new(RefCell::new(refine::Refine::default())); Rc::new(move |window: &AppWindow| { if !refine.borrow_mut().request() { return; } let weak = window.as_weak(); let render_now = render_now.clone(); let refine = refine.clone(); // A zero-delay `Timer` rather than `invoke_from_event_loop`: the // latter demands `Send`, and every piece of state here is `Rc` on // the UI thread by design. The delay being zero is the point — this // is "after the queued input has drained", not a throttle. slint::Timer::single_shot(std::time::Duration::ZERO, move || { let Some(window) = weak.upgrade() else { return }; let Some((frame, settle)) = refine.borrow_mut().tick() else { return; }; render_now(&window, frame == refine::Frame::Draft); let Some(token) = settle else { return }; // TRACES: FR-DSP-4 // One timer per draft frame, and only the newest is honoured, so // the sharp frame waits for the gesture to stop rather than for // the first draft to age. A superseded timer wakes, finds its // token stale, and does nothing — cheaper than holding a `Timer` // to restart, and it keeps the rule where the tests can see it. let weak = window.as_weak(); slint::Timer::single_shot(SETTLE_DELAY, move || { let Some(window) = weak.upgrade() else { return }; if refine.borrow_mut().settle(token).is_some() { render_now(&window, false); } }); }); }) } /// Build `show`: load the entry at `index` and put it on screen. /// /// The local half of the two load routes view-composition.md describes — /// `wire_remote_open` is the other, fetching bytes from the server instead of /// reading a path — and each runs the same post-load sequence independently. #[allow(clippy::too_many_arguments)] fn build_show( entries: &Rc>>, index: &Rc>, session: &Rc>>, redraw: &Rc, gpu: &Option, rows: &Rc>, open_image: &presets::OpenImage, library: &Rc, viewport: &Rc>, inspection: &Rc>, ) -> Rc { let entries = entries.clone(); let index = index.clone(); let session = session.clone(); let redraw = redraw.clone(); let gpu = gpu.clone(); let rows = rows.clone(); let open_image = open_image.clone(); let library = library.clone(); let viewport = viewport.clone(); let inspection = inspection.clone(); { let entries = entries.clone(); let index = index.clone(); let session = session.clone(); let redraw = redraw.clone(); let gpu = gpu.clone(); let rows = rows.clone(); let open_image = open_image.clone(); let library_for_show = library.clone(); let viewport_for_show = viewport.clone(); let inspection_for_show = inspection.clone(); Rc::new(move |window: &AppWindow| { // The image about to be replaced is the last chance to persist its // edit — stepping to the next frame is as much a departure as // going back to the grid. presets::save_open_edit(window, &open_image.borrow(), &session, &library_for_show); let i = *index.borrow(); // Cloned rather than held: `load` below is slow, and keeping the // list borrowed across it would panic the moment anything else // touched `entries`. let Some(path) = entries.borrow().get(i).cloned() else { return; }; let path = path.as_path(); let name = path .file_name() .unwrap_or_default() .to_string_lossy() .to_string(); reset_view_state(window); window.set_filename(name.clone().into()); window.set_index(i as i32); match load(gpu.as_ref(), dr_decode::default(), path) { Ok(l) => { window.set_load_error("".into()); let capture = window.global::(); capture.set_camera(describe_camera(&l.meta).into()); capture.set_exposure(describe_exposure(&l.meta).into()); capture.set_dimensions(format!("{} × {}", l.width, l.height).into()); // Composed by the session rather than here, because only it // knows whether the lookup found anything — the header can // name a lens the database has never heard of, which is the // ordinary case for third-party and adapted glass and must // read as a fact rather than as a failure. capture.set_lens( l.session .as_ref() .map(|s| s.lens_summary()) .unwrap_or_default() .into(), ); // The panel is built from what the pipeline reports, so // this code names no operation (FR-DEV-3a). match l.session { Some(s) => { window.global::().set_enabled(true); *session.borrow_mut() = Some(s); // A local file stores its edit beside itself. Read // *before* the first render, so an edited // photograph never flashes up at its defaults. *open_image.borrow_mut() = presets::Stored::Local(path.to_path_buf()); if let Some(sidecar) = presets::load_local(path) { presets::apply_stored_edit(window, &sidecar, &session, &rows); } // TRACES: FR-UI-4 // And under the same magnifier as the frame // before it, if one was up. After the sidecar, // because the point is a fraction of the framed // image and the sidecar is what decides how the // frame is cropped. resume_inspection(&session, &viewport_for_show, &inspection_for_show); // Through `sync_rows` rather than setting rows // directly, so the curve's drawn shape is // refreshed by the same path that refreshes the // controls — one place to keep them in step. sync_rows(window, &rows, &session); redraw(window); } None => { // No sensor data: show the preview and disable // the controls rather than offering sliders that // would do nothing. *session.borrow_mut() = None; *open_image.borrow_mut() = presets::Stored::Nowhere; rows.set_vec(Vec::::new()); window.global::().set_enabled(false); if let Some(image) = l.fallback { window.set_canvas(image); } } } log::info!("{name}: {}×{}", l.width, l.height); } Err(e) => { // A failure on one image must not stop browsing (FR-RAW-4). log::warn!("{name}: {e}"); *session.borrow_mut() = None; window.global::().set_enabled(false); window.set_load_error(e.into()); let capture = window.global::(); capture.set_camera("".into()); capture.set_exposure("".into()); capture.set_dimensions("".into()); capture.set_lens("".into()); } } }) } } /// The grid's paths are *remote*: there is no local file to open, so the /// click starts a download and the image appears when it lands. That is a /// whole RAW file over WebDAV, so the wait is real and has to be visible — /// the status line says so rather than leaving a blank frame. /// /// The other half of the two load routes view-composition.md describes; see /// [`build_show`] for the local one. #[allow(clippy::too_many_arguments, clippy::type_complexity)] fn wire_remote_open( window: &AppWindow, open_from_library: &Rc>>>, library: &Rc, session: &Rc>>, redraw: &Rc, rows: &Rc>, gpu: &Option, activity: &Rc, open_image: &presets::OpenImage, viewport: &Rc>, inspection: &Rc>, prefetch: &Rc, ) { let open_from_library = open_from_library.clone(); let library = library.clone(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); let gpu = gpu.clone(); let activity = activity.clone(); let open_image = open_image.clone(); let viewport = viewport.clone(); let inspection = inspection.clone(); let prefetch = prefetch.clone(); { let weak = window.as_weak(); let library = library.clone(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); let gpu = gpu.clone(); let activity = activity.clone(); let open_image = open_image.clone(); let viewport = viewport.clone(); let inspection = inspection.clone(); let prefetch = prefetch.clone(); *open_from_library.borrow_mut() = Some(Rc::new(move |path: String| { let Some(w) = weak.upgrade() else { return }; // Whatever was open before is being replaced; persist its edit // before the identity below is overwritten. presets::save_open_edit(&w, &open_image.borrow(), &session, &library); let name = path.rsplit('/').next().unwrap_or(&path).to_string(); reset_view_state(&w); w.set_filename(name.clone().into()); w.set_load_error("".into()); let capture = w.global::(); capture.set_camera("".into()); capture.set_exposure("".into()); capture.set_dimensions("".into()); // The grid is one image at a time, so next/previous have nothing // to walk. Shown as 1 of 1 rather than left reading 0. w.set_index(0); w.set_total(1); let Some(conn) = library.credentials() else { w.set_load_error("no library session".into()); return; }; // TRACES: FR-CAT-8 | FR-NC-8 // Where this image's edit belongs. The uuid comes from the catalog // so every device names the same version; without one there is // nowhere to save to, and the image opens read-only as far as // persistence is concerned rather than writing to an invented // identity that would never merge. *open_image.borrow_mut() = match library.version_uuid_for_path(&path) { Some(version_uuid) => presets::Stored::Remote { path: path.clone(), version_uuid, }, None => { log::debug!("no catalog version for {path}; edits will not persist"); presets::Stored::Nowhere } }; // The sidecar is fetched alongside the image rather than after it. // It is a few kilobytes against tens of megabytes, so it costs // nothing to have in hand by the time there is a session to apply // it to — and starting it here means the edit is ready when the // photograph is, instead of the image appearing at its defaults // and visibly changing a moment later. let sidecar_rx = library::spawn_sidecar_fetch( conn.clone(), path.clone(), library.sidecar_cache_dir().unwrap_or_default(), library.is_offline(), ); // TRACES: FR-NC-6a // The cache is consulted first, so a second open of the same // photograph is a disk read rather than a second download of tens // of megabytes — and so a session's worth of images stays // openable when the connection goes. let cache = library.cache_context(&path); if cache.is_none() { log::debug!("no originals cache for {path}; fetching every time"); } log::info!("fetching {path} for develop"); w.set_load_error("Downloading…".into()); let rx = library::spawn_full_fetch(conn, path.clone(), cache); // The one transfer the user is actively waiting on. It gets a row // like any other, so a download that is still running after they // give up and go back to the grid is still accounted for. // // No denominator: `spawn_full_fetch` reports a result, not bytes as // they arrive, so the honest bar here is the indeterminate one. let job = activity.begin(activity::Kind::Download, format!("Downloading {name}")); // Polled on the UI thread rather than joined: a join would freeze // the window for the length of the download. let weak = w.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); let gpu = gpu.clone(); // Cloned for the timer closure: the outer callback is an `Fn` and // may run again for the next photograph. let library = library.clone(); let prefetch = prefetch.clone(); let path = path.clone(); let viewport = viewport.clone(); let inspection = inspection.clone(); let sidecar_rx = Rc::new(sidecar_rx); let timer = Rc::new(slint::Timer::default()); let held = timer.clone(); timer.start( slint::TimerMode::Repeated, std::time::Duration::from_millis(50), move || { let Ok(got) = rx.try_recv() else { return }; // Landed — this timer has done its job. held.stop(); let Some(w) = weak.upgrade() else { return }; let bytes = match got { Ok(b) => b, Err(e) => { job.fail(e.message.clone()); log::warn!("{name}: {e}"); // Offline needs its own words. "network error: // connection refused" over a photograph the user // just clicked reads as a broken app; the real // situation is that this particular image was // never stored on this device, and the fix is to // download it while there is a connection. w.set_load_error(if e.offline { "Offline — this image is not stored on this device.".into() } else { slint::SharedString::from(e.message) }); return; } }; job.finish(activity::describe_bytes(bytes.len() as u64)); log::info!("{name}: {} bytes fetched", bytes.len()); // TRACES: FR-NC-6a | FR-UI-4 // Now, and not when the click happened: started earlier // they would share the connection with the download the // user is watching, and the photograph on screen is the // one that matters. Before the decode below, so the // transfers are under way while the UI thread is busy. prefetch_neighbours(&library, &prefetch, &path); match load_bytes(gpu.as_ref(), dr_decode::default(), &bytes) { Ok(l) => { w.set_load_error("".into()); let capture = w.global::(); capture.set_camera(describe_camera(&l.meta).into()); capture.set_exposure(describe_exposure(&l.meta).into()); capture.set_dimensions(format!("{} × {}", l.width, l.height).into()); match l.session { Some(mut s) => { w.global::().set_enabled(true); // TRACES: FR-CULL-10 // Who is in this photograph, so a // segmentation run can say "Anna" where // the model can only say "person". Read // here because this is the one moment the // catalog and the image id are both in // reach; every run afterwards gets them // for free. if let Some(id) = library.image_id_for_path(&path) { let cat = library.catalog(); let borrowed = cat.borrow(); if let Some(c) = borrowed.as_ref() { match crate::identity::named_boxes_normalised(c, id) { Ok(n) if !n.is_empty() => { log::info!( "{} known face(s) in this photograph", n.len() ); s.set_face_names(n); } Ok(_) => {} Err(e) => { log::debug!("reading known faces: {e}") } } } } *session.borrow_mut() = Some(s); // TRACES: FR-CAT-8 // The stored edit, if it has landed. It // was started before the download of a // file thousands of times its size, so in // practice it has; `apply_when_ready` // covers the case where it has not rather // than blocking the UI thread on a socket. apply_when_ready( &w, sidecar_rx.clone(), &session, &rows, &redraw, ); // TRACES: FR-UI-4 // Under the same magnifier as the last // frame, on the terms `resume_inspection` // sets out. The sidecar may still be in // flight here, unlike the local path — so // a photograph whose stored crop lands a // moment later is inspected against the // uncropped frame until it does, which // moves the point rather than losing it. resume_inspection(&session, &viewport, &inspection); sync_rows(&w, &rows, &session); redraw(&w); } None => { *session.borrow_mut() = None; rows.set_vec(Vec::::new()); w.global::().set_enabled(false); if let Some(image) = l.fallback { w.set_canvas(image); } } } log::info!("{name}: {}×{}", l.width, l.height); } Err(e) => { log::warn!("{name}: {e}"); *session.borrow_mut() = None; w.global::().set_enabled(false); w.set_load_error(e.into()); } } }, ); })); } } /// The window chrome shared by every view: canvas resize (which reaches the /// develop viewport), which display the window is on, the layout class that /// picks compact versus expanded, the two collapsible columns, and the /// platform's back gesture. fn wire_shell( window: &AppWindow, viewport: &Rc>, redraw: &Rc, display: &Rc, ) { let viewport = viewport.clone(); let redraw = redraw.clone(); let display = display.clone(); // Track the canvas size so the adjust pass renders at viewport // resolution rather than sensor resolution (FR-DSP-1). // // TRACES: FR-DSP-8 // Slint reports the canvas in *logical* pixels, which is the box the // compositor will draw into and not the number of device pixels it will // fill. `display_ui::physical` converts, so that a fractionally scaled // desktop is presented 1:1 rather than resampled — see that function for // why a soft canvas is the failure being avoided here. { let weak = window.as_weak(); let viewport = viewport.clone(); let redraw = redraw.clone(); let display = display.clone(); window.on_canvas_resized(move |w_px, h_px| { let Some(w) = weak.upgrade() else { return }; let logical = (w_px.max(1) as u32, h_px.max(1) as u32); let size = display.canvas_resized(&w, logical); if *viewport.borrow() == size { return; } *viewport.borrow_mut() = size; redraw(&w); }); } // TRACES: FR-DSP-8 | FR-DSP-6 // And which display that canvas is on, from now until the window closes. display_ui::attach(window, &display, &viewport, redraw.clone()); // FR-UI-1: layout class from window width. Computed here rather than in // Slint because a property that both derives from and feeds the layout is // a binding loop. let panels = std::rc::Rc::new(PanelChoices::default()); // N6: what the last window line that carried a measurement reported. // Shared with the resize callback below, which is where every reading // after the first one arrives. See `window_metrics_level`. let reported_geometry = std::rc::Rc::new(std::cell::Cell::new(None::<(bool, u32)>)); // Where `column-below` stood a moment ago, which is the other half of the // hysteresis in `column_below`. Not in `PanelChoices`: that records where // the *user* disagreed with a layout class, and this is neither a class nor // anything the user has said — it is the last answer, kept so the next one // can decline to contradict it over a pixel of drag. let dock = std::rc::Rc::new(std::cell::Cell::new(None::)); { let weak = window.as_weak(); let panels = panels.clone(); let reported_geometry = reported_geometry.clone(); let dock = dock.clone(); window.on_window_resized(move |width, height| { let Some(window) = weak.upgrade() else { return }; apply_layout_class(&window, width, height, &panels, &dock); log_window_metrics(&window, window_metrics_level(&window, &reported_geometry)); }); } { let size = window.window().size(); let scale = window.window().scale_factor().max(0.01); apply_layout_class( window, size.width as f32 / scale, size.height as f32 / scale, &panels, &dock, ); // N6: the reading beside the one the layout class is derived from, // which on a desktop is taken before the window has been mapped and so // reads zero. `window_metrics_level` is what decides whether that is // worth `info`; here it will not be, and the first resize carries the // measurement instead. log_window_metrics(window, window_metrics_level(window, &reported_geometry)); } // FR-UI-2: the two collapsible columns, opened and closed by hand. { let weak = window.as_weak(); let panels = panels.clone(); window.on_toggle_panel(move || { let Some(w) = weak.upgrade() else { return }; let open = !w.get_panel_visible(); panels.panel.set(Some(open)); w.set_panel_visible(open); }); } { let weak = window.as_weak(); let panels = panels.clone(); window.on_toggle_collections(move || { let Some(w) = weak.upgrade() else { return }; let open = !w.get_collections_visible(); panels.collections.set(Some(open)); w.set_collections_visible(open); }); } // Android's back gesture, and Escape on a keyboard. { let weak = window.as_weak(); window.on_back_requested(move || { let Some(w) = weak.upgrade() else { return false; }; back_one_step(&w) }); } } /// TRACES: FR-NC-6a /// TRACES: FR-NC-6a | FR-UI-4 /// Ask for the photographs around `path` — as many each side as the settings /// say — to be fetched into the cache. /// /// Every gate the prefetcher documents is applied here, because this is the /// side that can see the settings and the network: nothing while offline, and /// nothing into a cache that would not keep the bytes. A neighbour the cache /// cannot place — a row that has scrolled out of the window — is left out /// rather than fetched to nowhere. /// /// Called on every landing, including one with no neighbours: an empty wish /// cancels whatever the previous photograph asked for, which is right — those /// were *its* neighbours. fn prefetch_neighbours( library: &Rc, prefetch: &library::Prefetcher, path: &str, ) { let Some(conn) = library.credentials() else { return; }; let jobs: Vec = if library.is_offline() { Vec::new() } else { library .neighbours_of(path, library.fetch_ahead()) .into_iter() .filter_map(|path| { let cache = library.cache_context(&path).filter(|c| c.store)?; Some(library::PrefetchJob { path, cache }) }) .collect() }; if !jobs.is_empty() { log::debug!("fetching {} neighbour(s) of {path} ahead", jobs.len()); } prefetch.want(conn, jobs); } /// TRACES: FR-NC-6a /// Show the prefetcher's transfers in the activity list while they run. /// /// A row each, like the download the user is waiting on, so a transfer that /// is using the connection is never invisible. Removed rather than kept when /// it ends, on the same grounds as a thumbnail batch: routine work the user /// never asked for by name would push a failed transfer out of the list. A /// prefetch that fails says nothing here — the click that wants the /// photograph will try again and report in its own row. fn show_prefetches(prefetch: &Rc, activity: &Rc) { let prefetch = prefetch.clone(); let activity = activity.clone(); let mut live: std::collections::HashMap = Default::default(); // Held by its own closure for the life of the window, like every other // drain here. let timer = Rc::new(slint::Timer::default()); let held = timer.clone(); timer.start( slint::TimerMode::Repeated, std::time::Duration::from_millis(200), move || { let _keep = &held; for event in prefetch.poll() { match event { library::PrefetchEvent::Started(path) => { let name = path.rsplit('/').next().unwrap_or(&path); let job = activity .begin(activity::Kind::Download, format!("Fetching {name} ahead")); live.insert(path, job); } library::PrefetchEvent::Ended(path) => { if let Some(job) = live.remove(&path) { job.finish_quietly(); } } } } }, ); } /// What the originals cache is holding, for the settings page. /// /// Pinned and passive are reported separately because they answer different /// questions: the passive figure is what the budget above it governs, while /// the pinned figure is disk the user asked for and no ceiling will reclaim. /// One combined number would make the budget look wrong whenever a large /// collection was pinned. /// TRACES: FR-CULL-8 /// The two face lines on the settings page: whether the chosen detector's file /// is installed, and how far the chosen pipeline has got through the library. /// /// One function because they move together. A detector picked from the list /// changes which file has to exist *and* which `model_id` the coverage is /// counted under, and a page that updated one without the other would report /// "no model installed" over a coverage figure for a different model. fn refresh_face_status( window: &AppWindow, library: &Rc, detector: dr_types::FaceDetector, ) { let store = library .session() .and_then(|c| dr_thumbs::ThumbStore::open(&library::thumbs_dir(&c.account)).ok()); let models = library .session() .and_then(|c| library::face_models(&c.account, detector)); identity_ui::refresh_coverage( window, &library.catalog(), store.as_ref(), inference::model_id(detector), models.as_ref().is_some_and(|m| m.eyes.is_some()), ); window.set_identity_model_missing(models.is_none()); } fn describe_cache_usage(library: &Rc) -> 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>, } /// TRACES: FR-UI-1 | FR-UI-7 /// Where the adjustment groups are chosen from, for this device and this /// photographer. /// /// **Not the layout class, and not derived from one.** `apply_layout_class` /// below answers "how much room is there" from the window's width, and was /// right to refuse a device check — a narrow desktop window wants the compact /// layout exactly as a small screen would. This answers "what is the user /// pointing with", which width cannot stand in for: DarkRoom's two targets are /// a 12-inch tablet and a desktop, the same size and the same layout class, /// and only one of them has no hover, no modifier keys and a finger for a /// cursor. /// /// `ui-navigation.md` D-N2 decided against any divergence between the targets /// and this is the exception to it, argued on D-N2's own ground: it identified /// input as the real difference and then assumed touch changes hit regions /// rather than layout. A horizontal strip that pans, at the top of a column, /// against a rail of finger-sized targets down the edge the hand is already /// on, is where that assumption runs out. fn groups_in_rail(settings: &settings_ui::SettingsController) -> bool { settings .snapshot() .develop .group_navigation .groups_in_rail(dr_plat::is_touch_first()) } /// TRACES: FR-UI-1 /// How loudly to say the window's size, given what has already been said. /// /// Three constraints meet here and only one arrangement satisfies all of them. /// /// *It has to reach the device.* `android_main` caps the `log` facade at /// `info` (`darkroom-android`), so anything at `debug` is discarded before it /// reaches liblog and `adb logcat` never sees it. N6's whole deliverable is a /// number read off a tablet, so a reading worth having must be `info`. /// /// *It must not be the shape of a drag.* Dragging a desktop window emits a /// resize per frame, and every accepted record is also appended to the on-disk /// log (NFR-OPS-1). Hundreds of `info` lines saying the window is a pixel /// wider is not a diagnostic, so a resize that only moves the size is `debug`. /// /// *A window has no size until it has been mapped, and the size it reports /// first is not the one it settles on.* The startup call runs before the event /// loop, where `size()` is 0 x 0 and `scale_factor()` is 1.0; the first resize /// after it arrives at whatever the platform opened with and at scale 1.0, /// because the display's real scale is applied a beat later. Watched on X11 /// the sequence is 0 x 0, then 360 x 320 at 1.0, then 1100 x 720 at 2.0 — and /// a rule that reports only the first of those puts a number in logcat that /// is not the window's. /// /// So the pair that decides is **the scale factor and the orientation**, which /// is exactly the pair N6 is asking for and exactly the pair a drag leaves /// alone. A window with no area is not a reading at all and records nothing. /// Every later change to either half — the display's scale resolving, the /// window moving to a monitor with a different density, the tablet being /// turned over — is a new answer to N6's question and goes out at `info`; the /// rest is `debug`. Scale is compared as the line prints it, to two decimals, /// so two records that would read identically cannot both claim `info`. fn window_metrics_level( window: &AppWindow, reported: &std::cell::Cell>, ) -> log::Level { let size = window.window().size(); if size.width == 0 || size.height == 0 { return log::Level::Debug; } let geometry = ( size.height >= size.width, (window.window().scale_factor().max(0.01) * 100.0).round() as u32, ); if reported.replace(Some(geometry)) == Some(geometry) { log::Level::Debug } else { log::Level::Info } } /// TRACES: FR-UI-1 /// The window's own measurements, named plainly enough to be read out of /// `adb logcat`. /// /// `ui-navigation.md` N6. Every figure in D-N7's table was computed at a /// *guessed* scale factor, because nothing in this repository has ever /// recorded the density Android reports: the tablet's panel is 3000 x 1920 /// physical, and the two plausible densities put the dock's width at 900 or /// 1037 and its available height 200px apart. The only way to settle it is to /// ask the window on the device and read the answer off the wire. /// /// Both coordinate systems, on one line, so they can be checked against each /// other. Logical alone cannot be trusted when the scale is the thing in /// doubt, and a physical size that does not divide into the logical one by the /// scale reported beside it says the reading is of something other than the /// panel — an emulator, a scaled-down desktop window, a display that changed /// under the application. fn log_window_metrics(window: &AppWindow, level: log::Level) { let physical = window.window().size(); // The same floor `apply_layout_class`'s caller uses: a scale of zero is // not a number this can divide by, and a window that reports one is // reporting nothing useful anyway. let scale = window.window().scale_factor().max(0.01); log::log!( level, "window: {:.0}x{:.0} logical at scale {scale:.2} ({}x{} physical)", physical.width as f32 / scale, physical.height as f32 / scale, physical.width, physical.height ); } /// TRACES: FR-UI-1 /// Which side of the photograph the develop column belongs on, given the shape /// of the window and where the answer stood before (D-N7). /// /// **Not a layout class.** The class is decided by width and says how much room /// there is; this is decided by aspect and says which way round the room is. A /// 960-wide portrait window is expanded and wants the dock; a 1500-wide /// landscape one is expanded and does not — so the two cannot be folded /// together, and `PanelChoices` does not remember this one. Closing the column /// in landscape closes the dock in portrait, because it is the same column. /// /// `previously` is `None` on the first call, before any resize has been /// reported. There is nothing to be hysteretic about yet, so the entering /// threshold decides alone: a window that opens inside the dead band opens with /// the column beside the photograph, which is what every window that is not /// clearly tall gets. fn column_below(width: f32, height: f32, previously: Option) -> bool { // A window with no width has no aspect. It happens between a minimise and // the resize that follows it, and answering `false` here would swing the // column back beside the photograph for the one frame in between. if width <= 0.0 { return previously.unwrap_or(false); } let aspect = height / width; match previously { Some(true) => aspect > DOCK_LEAVE_ASPECT, _ => aspect >= DOCK_ENTER_ASPECT, } } fn apply_layout_class( window: &AppWindow, width: f32, height: f32, panels: &PanelChoices, dock: &std::cell::Cell>, ) { let expanded = width >= EXPANDED_MIN_WIDTH; window.set_expanded(expanded); window.set_layout_class(if expanded { "expanded" } else { "compact" }.into()); // FR-UI-1: which axis the column is on, set from here for the same reason // the class is — the develop view's frame reads it, so deriving it from // `root.width` inside that frame would be the binding loop the note on // `expanded` in `app.slint` describes. let below = column_below(width, height, dock.get()); dock.set(Some(below)); window.set_column_below(below); // FR-UI-1: the ceiling on the develop column, computed here for the same // reason the class is — a width that both derives from and feeds the // layout is a binding loop in Slint. window.set_panel_max_width((width * PANEL_MAX_FRACTION).max(PANEL_MIN_WIDTH)); if panels.class.get() != Some(expanded) { panels.class.set(Some(expanded)); panels.panel.set(None); panels.collections.set(None); } window.set_panel_visible(panels.panel.get().unwrap_or(expanded)); window.set_collections_visible(panels.collections.get().unwrap_or(expanded)); } /// TRACES: FR-UI-5 /// One step back, and whether there was one to take. /// /// The Escape half is FR-UI-5's "keyboard shortcuts cover navigation". The /// Android back gesture answers to the same handler and has no numbered /// requirement of its own — the register was written before phones and tablets /// had a platform section, and §1.3 still lists no navigation requirement. /// /// This is what Android's back gesture and the Escape key both resolve to. The /// order is the order the states were entered in, innermost first: a mode /// within a view is left before the view is, because that is what the user /// most recently did and so what they most likely mean to undo. /// /// Returning `false` means this is the top of the stack. The shell passes that /// straight back to the platform as an unhandled key, which on Android closes /// the activity — the behaviour every application there has, and the reason /// this answers with a bool rather than swallowing the gesture. fn back_one_step(w: &AppWindow) -> bool { // TRACES: FR-UI-5 | FR-UI-4 // The Identity screen has no representation in `NavState`: nothing here // records which view it replaced, only `identity_ui`'s own // `came_from_library`. So back leaves it the way its own "‹ Library" / // "‹ Develop" button does, through `identity-close`, which knows where it // came from — rather than through the general case below, which would // write `active-view` without that knowledge. It used to swallow the key // and do nothing, which left Escape and Android's Back dead on a whole // screen. if w.get_active_view() == View::Identity { w.invoke_identity_close(); return true; } let state = NavState { settings: w.get_active_page() == Page::Settings, launch: w.get_active_view() == View::Launch, browsing: w.get_launch_browsing(), library: w.get_active_view() == View::Library, mode: w.global::().get_view_mode(), zoomed: w.get_zoomed(), // Files named on the command line have no grid behind them — the same // condition the status strip uses to decide whether to offer the way // back at all. has_grid: w.global::().get_library_total() > 0, scoped: w.get_collection_selected() != 0, }; let Some(step) = back_step(state) else { return false; }; match step { BackStep::CloseSettings => w.invoke_settings_close(), BackStep::CancelBrowse => w.invoke_launch_browse_cancel(), BackStep::LeaveMode => w.invoke_mode_picked(ViewMode::Photo), // TRACES: FR-UI-4 // Through the toggle rather than through `zoom_reset`, because a // zoomed view is now something the photographer is *holding* across // photographs: putting it down has to put the magnifier down too, or // the next frame would open under a magnification that the back // gesture had just dismissed. BackStep::ResetZoom => w.invoke_inspect_toggled(-1.0, -1.0), BackStep::ToLibrary => w.invoke_back_to_library(), BackStep::ClearScope => w.invoke_collection_select(0), } true } /// Where the interface is, as far as going back is concerned. /// /// A flat snapshot rather than the window itself, so the ordering below can be /// stated and tested without a Slint backend: which of two states is left first /// is the whole of this feature, and it is the part that is easy to get subtly /// wrong when it is spelled out in nested `if`s over live properties. // No `Eq`: `ViewMode` is generated by Slint and derives only `PartialEq`, // which is all the comparisons below need. `Default` still derives, and it // gives `mode` the enum's own first variant — `photo`, which is what "no mode" // means and what the tests below want as their baseline. #[derive(Clone, Copy, Debug, Default, PartialEq)] struct NavState { settings: bool, launch: bool, browsing: bool, library: bool, /// Which develop mode is on, if any. One field rather than one flag per /// mode, so "leave the innermost" cannot be asked of two at once — the /// ordering below would have had to invent an answer for a state the /// interface can no longer be in. mode: ViewMode, zoomed: bool, has_grid: bool, scoped: bool, } /// What one step back does, or `None` at the top of the stack. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum BackStep { CloseSettings, CancelBrowse, /// Leave whichever develop mode is on — crop or local — and return to the /// whole photograph. One step for both, because there is one mode at a /// time and "back" means the same thing from either. LeaveMode, ResetZoom, ToLibrary, ClearScope, } fn back_step(s: NavState) -> Option { // 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 ); } #[test] fn a_window_taller_than_it_is_wide_docks_the_column_below() { // The tablet in portrait at either scale factor D-N7 considers, and a // desktop window dragged into the same shape. Aspect, not width: the // first of these is expanded by width and still wants the dock. assert!(column_below(960.0, 1500.0, None)); assert!(column_below(1097.0, 1714.0, None)); assert!(column_below(700.0, 1100.0, Some(false))); } #[test] fn a_window_wider_than_it_is_tall_keeps_the_column_beside() { // Landscape, square, and the tablet's other orientation. The last is // the one that matters: rotating back has to bring the column back. assert!(!column_below(1500.0, 900.0, Some(false))); assert!(!column_below(1000.0, 1000.0, Some(true))); assert!(!column_below(1500.0, 960.0, Some(true))); } #[test] fn a_window_resized_across_square_does_not_flap() { // The dead band, from both sides. Between 1.15 and 1.25 the answer is // whatever it already was, which is the whole reason there are two // thresholds rather than one — a diagonal drag crosses this range for // as long as the pointer is down. let width = 1000.0; for aspect in [1.16, 1.20, 1.24] { let height = width * aspect; assert!( column_below(width, height, Some(true)), "{aspect} from below" ); assert!( !column_below(width, height, Some(false)), "{aspect} from beside" ); } } #[test] fn the_first_measurement_has_no_state_to_be_hysteretic_about() { // Before any resize has been reported there is no previous answer, so // the entering threshold decides alone and the dead band reads as // "beside" — the arrangement every window that is not clearly tall // gets. And a window with no width at all keeps whatever it had, so a // minimise does not swing the column across for one frame. assert!(!column_below(1000.0, 1200.0, None)); assert!(column_below(1000.0, 1250.0, None)); assert!(!column_below(0.0, 1200.0, None)); assert!(column_below(0.0, 400.0, Some(true))); } }