//! Slint interface for DarkRoom. //! //! A viewer with a develop panel: open a folder of RAW files, decode and //! demosaic on the GPU, and adjust. //! //! **Read before assuming A1 is proven.** Slint's public API for adopting an //! externally created wgpu texture is not wired up here; this build uploads //! through `SharedPixelBuffer`, which *is* a CPU round-trip — explicitly the //! thing ARCH §6.1 forbids in production. Spike S1 replaces it. Until then A1 //! is unvalidated, and the develop path pays a readback per frame that the //! finished one will not. //! //! **The develop panel is generated, not written.** [`develop`] asks the //! pipeline what parameters it has and builds a control per answer; no code //! in `ui/` names an operation or knows a shader exists (FR-DEV-3a). mod develop; mod labels; use std::cell::RefCell; use std::path::{Path, PathBuf}; use std::rc::Rc; use anyhow::Result; use dr_decode::{Metadata, PreviewSize}; pub use develop::DevelopSession; pub mod launch; pub mod launch_ui; slint::include_modules!(); /// TRACES: FR-DSP-1 | NFR-RES-1 /// Longest edge the viewer renders at. /// /// FR-DSP-1: work at the resolution the viewport needs, not the source /// resolution. A 5472×3648 preview is 79.8 MB of RGBA; at 2048 it is 11 MB, /// which is what keeps a folder browsable within NFR-RES-1's budget. const MAX_DISPLAY_DIM: u32 = 2048; /// TRACES: FR-UI-1 | FR-UI-2 | M-16 /// Width at which the expanded layout appears (FR-UI-1). /// /// Logical pixels, not a device check — a narrow desktop window gets the /// compact layout exactly as a tablet in portrait would. const EXPANDED_MIN_WIDTH: f32 = 820.0; /// Everything loaded for the currently displayed image. struct Loaded { /// A develop session where the file could be decoded to sensor data. /// `None` for a JPEG or a body rawler cannot decode, in which case /// `fallback` carries an embedded preview and the adjust panel is /// disabled rather than shown doing nothing. session: Option, fallback: Option, meta: Metadata, width: u32, height: u32, } /// Load one image, preferring the full develop path. /// /// Reads the whole file: demosaic needs every photosite. The remote path /// (FR-NC-3) fetches only a byte range for *browsing*, which is why the /// preview API is separate — this is the develop path, and it is expected to /// be expensive. fn load(ctx: Option<&dr_gpu::GpuContext>, path: &Path) -> Result { // A VFS placeholder holds one byte and reading it triggers no fetch // (ARCH §9.0). Say so plainly rather than reporting a decode failure. if path .file_name() .map(|n| n.to_string_lossy().ends_with(dr_types::PLACEHOLDER_SUFFIX)) .unwrap_or(false) { return Err("not downloaded — Nextcloud placeholder".into()); } let bytes = std::fs::read(path).map_err(|e| e.to_string())?; let meta = dr_decode::metadata(&bytes).unwrap_or_default(); // Try sensor data first. A failure here is expected for JPEGs and for // bodies rawler does not know, and must not stop the image displaying // (FR-RAW-4). if let Some(ctx) = ctx { match dr_decode::decode(&bytes) { Ok(raw) => match DevelopSession::open(ctx, &raw) { 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}"), }, Err(e) => log::info!("no sensor data ({e}); showing preview"), } } // Fall back to the embedded preview, which is all a JPEG has anyway. let mut preview = dr_decode::extract_preview(&bytes, PreviewSize::Screen).map_err(|e| e.to_string())?; preview.downscale_to(MAX_DISPLAY_DIM); let buffer = slint::SharedPixelBuffer::::clone_from_slice( &preview.rgba, preview.width, preview.height, ); Ok(Loaded { session: None, fallback: Some(slint::Image::from_rgba8(buffer)), meta, width: preview.width, height: preview.height, }) } /// Collect displayable images from file or directory arguments. fn collect(paths: &[PathBuf]) -> Vec { let mut out = Vec::new(); for p in paths { if p.is_dir() { let Ok(entries) = std::fs::read_dir(p) else { continue; }; let mut found: Vec = entries .flatten() .map(|e| e.path()) .filter(|p| p.is_file() && is_supported(p)) .collect(); found.sort(); out.extend(found); } else if p.is_file() && is_supported(p) { out.push(p.clone()); } } out } /// Whether a path names an image DarkRoom can catalogue. /// /// Includes VFS placeholders: `IMG.CR2.nextcloud` is an image the user has, /// just not locally (ARCH §9.0). Excluding it would make a synced folder look /// empty rather than offline, which is the opposite of FR-NC-6c's intent. fn is_supported(p: &Path) -> bool { let name = p .file_name() .map(|n| n.to_string_lossy()) .unwrap_or_default(); let name = name .strip_suffix(dr_types::PLACEHOLDER_SUFFIX) .unwrap_or(&name); name.rsplit_once('.') .map(|(_, ext)| ext.to_ascii_lowercase()) .and_then(|e| dr_types::Format::from_extension(&e)) .is_some() } /// Push current parameter values back to the interface. /// /// The controls are not self-updating: the core clamps values, so what the /// user dragged to and what the parameter became can differ, and the control /// must show the latter. /// **Updates rows in place; never replaces the model.** Assigning a fresh /// `ModelRc` tears down and rebuilds every row element — including the /// `TouchArea` currently tracking the pointer — which cancels the drag in /// progress. The symptom is a slider that jumps on click but cannot be /// dragged, because each move event destroys the thing that would deliver /// the next one. fn sync_rows( window: &AppWindow, rows: &Rc>, session: &Rc>>, ) { use slint::Model as _; let (current, samples) = match session.borrow().as_ref() { Some(s) => (s.rows(), s.curve_samples()), None => (Vec::new(), Vec::new()), }; if current.len() == rows.row_count() { for (i, row) in current.into_iter().enumerate() { // Only touch rows that actually changed, so unrelated controls // are not needlessly invalidated. if rows.row_data(i).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. rows.set_vec(current); } // The drawn curve follows the points. Replacing this model wholesale is // safe where replacing `rows` was not: nothing in it is a drag target. window.set_curve_samples(slint::ModelRc::new(slint::VecModel::from(samples))); } /// TRACES: M-13 | M-14 /// Build and run the viewer. pub fn run(paths: Vec) -> Result<()> { let entries = Rc::new(collect(&paths)); log::info!("{} image(s) to browse", entries.len()); let window = AppWindow::new()?; // Launch screen: shown when there is nothing to display — no local paths // and no configured library. A user who has already signed in and chosen // a folder goes straight to their images (FR-NC-1). { let controller = launch_ui::LaunchController::new(); let show = controller.should_show(!paths.is_empty()); window.set_show_launch(show); launch_ui::wire(&window, controller, |session| { // Opening a remote library needs the scan-and-cache path, which // lands with the catalog. Reporting that plainly beats a button // that silently does nothing. log::info!("open library requested for {}", session.describe()); }); if show { log::info!("no library configured — showing the launch screen"); } } // The device is shared by demosaic and the adjust pass. Without one the // app still browses through the preview path, just without develop. let gpu = match pollster::block_on(dr_gpu::GpuContext::new_headless()) { Ok(ctx) => { log::info!("adapter: {} ({:?})", ctx.adapter_name(), ctx.backend()); window.set_adapter(ctx.adapter_name().into()); window.set_backend(format!("{:?}", ctx.backend()).to_uppercase().into()); Some(ctx) } Err(e) => { log::warn!("no GPU adapter: {e}"); window.set_backend("NO GPU".into()); None } }; window.set_total(entries.len() as i32); let index = Rc::new(RefCell::new(0usize)); // The current develop session, if the file yielded sensor data. let session: Rc>> = Rc::new(RefCell::new(None)); // One model for the lifetime of the window. Rows are mutated in place; // see `sync_rows` for why replacing it breaks dragging. let rows: Rc> = Rc::new(slint::VecModel::default()); window.set_adjust_rows(rows.clone().into()); // Viewport size, tracked so a re-render after a slider move matches it. let viewport = Rc::new(RefCell::new((1024u32, 768u32))); // Re-render the current session into the canvas. // // Called on every slider change, so it must do no more than run the // adjust pass — the demosaic is not repeated. let redraw: Rc = { let session = session.clone(); let viewport = viewport.clone(); Rc::new(move |window: &AppWindow| { let mut slot = session.borrow_mut(); let Some(s) = slot.as_mut() else { return }; let (w, h) = *viewport.borrow(); match s.render(w, h) { Ok(image) => { window.set_canvas(image); window.set_load_error("".into()); } Err(e) => { log::warn!("render failed: {e}"); window.set_load_error(e.into()); } } }) }; let show = { let entries = entries.clone(); let index = index.clone(); let session = session.clone(); let redraw = redraw.clone(); let gpu = gpu.clone(); let rows = rows.clone(); Rc::new(move |window: &AppWindow| { let i = *index.borrow(); let Some(path) = entries.get(i) else { return }; let name = path .file_name() .unwrap_or_default() .to_string_lossy() .to_string(); window.set_filename(name.clone().into()); window.set_index(i as i32); match load(gpu.as_ref(), path) { Ok(l) => { window.set_load_error("".into()); window.set_camera(describe_camera(&l.meta).into()); window.set_exposure(describe_exposure(&l.meta).into()); window.set_dimensions(format!("{} × {}", l.width, l.height).into()); // The panel is built from what the pipeline reports, so // this code names no operation (FR-DEV-3a). match l.session { Some(s) => { window.set_adjust_enabled(true); *session.borrow_mut() = Some(s); // Through `sync_rows` rather than setting rows // directly, so the curve's drawn shape is // refreshed by the same path that refreshes the // controls — one place to keep them in step. sync_rows(window, &rows, &session); redraw(window); } None => { // No sensor data: show the preview and disable // the controls rather than offering sliders that // would do nothing. *session.borrow_mut() = None; rows.set_vec(Vec::::new()); window.set_adjust_enabled(false); if let Some(image) = l.fallback { window.set_canvas(image); } } } log::info!("{name}: {}×{}", l.width, l.height); } Err(e) => { // A failure on one image must not stop browsing (FR-RAW-4). log::warn!("{name}: {e}"); *session.borrow_mut() = None; window.set_adjust_enabled(false); window.set_load_error(e.into()); window.set_camera("".into()); window.set_exposure("".into()); window.set_dimensions("".into()); } } }) }; // ---- Adjustment callbacks ------------------------------------------ // // Generic by construction: they carry indices into the capability list, // so adding an operation needs no change here (FR-DEV-3c). { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); window.on_param_changed(move |op, param, value| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.set_param(op, param, value); } sync_rows(&w, &rows, &session); redraw(&w); }); } { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); window.on_param_reset(move |op, param| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.reset_param(op, param); } sync_rows(&w, &rows, &session); redraw(&w); }); } { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); window.on_reset_all(move || { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.reset_all(); } sync_rows(&w, &rows, &session); redraw(&w); }); } { // A curve is one control spanning many parameters, so resetting it // clears all of them at once — resetting a single point would leave // a shape the user did not ask for. let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); window.on_curve_reset(move |op| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.reset_curve(op); } sync_rows(&w, &rows, &session); redraw(&w); }); } { let weak = window.as_weak(); let index = index.clone(); let entries = entries.clone(); let show = show.clone(); window.on_next_image(move || { let Some(w) = weak.upgrade() else { return }; if entries.is_empty() { return; } // Read, then write — `*x.borrow_mut() = *x.borrow() + 1` holds // both borrows at once and panics. let next = { let cur = *index.borrow(); (cur + 1) % entries.len() }; *index.borrow_mut() = next; show(&w); }); } { let weak = window.as_weak(); let index = index.clone(); let entries = entries.clone(); let show = show.clone(); window.on_prev_image(move || { let Some(w) = weak.upgrade() else { return }; if entries.is_empty() { return; } let prev = { let cur = *index.borrow(); if cur == 0 { entries.len() - 1 } else { cur - 1 } }; *index.borrow_mut() = prev; show(&w); }); } // Track the canvas size so the adjust pass renders at viewport // resolution rather than sensor resolution (FR-DSP-1). { let weak = window.as_weak(); let viewport = viewport.clone(); let redraw = redraw.clone(); window.on_canvas_resized(move |w_px, h_px| { let Some(w) = weak.upgrade() else { return }; let size = (w_px.max(1) as u32, h_px.max(1) as u32); if *viewport.borrow() == size { return; } *viewport.borrow_mut() = size; redraw(&w); }); } // FR-UI-1: layout class from window width. Computed here rather than in // Slint because a property that both derives from and feeds the layout is // a binding loop. { let weak = window.as_weak(); window.on_window_resized(move |width| { let Some(window) = weak.upgrade() else { return }; apply_layout_class(&window, width); }); } { let size = window.window().size(); let scale = window.window().scale_factor().max(0.01); apply_layout_class(&window, size.width as f32 / scale); } if !entries.is_empty() { show(&window); } window.run()?; Ok(()) } fn describe_camera(m: &Metadata) -> String { match (&m.make, &m.model) { (Some(make), Some(model)) => { // Model often repeats the make; "Canon Canon EOS 6D" reads badly. if model.starts_with(make.as_str()) { model.trim().to_string() } else { format!("{} {}", make.trim(), model.trim()) } } (_, Some(model)) => model.trim().to_string(), (Some(make), _) => make.trim().to_string(), _ => String::new(), } } fn describe_exposure(m: &Metadata) -> String { let mut parts = Vec::new(); if let Some(s) = m.shutter { // Photographers read fractions, not decimals. parts.push(if s >= 1.0 { format!("{s:.1}s") } else { format!("1/{}", (1.0 / s).round() as u32) }); } if let Some(a) = m.aperture { parts.push(format!("f/{a:.1}")); } if let Some(iso) = m.iso { parts.push(format!("ISO {iso}")); } if let Some(f) = m.focal_length { parts.push(format!("{f:.0}mm")); } parts.join(" ") } fn apply_layout_class(window: &AppWindow, width: f32) { let expanded = width >= EXPANDED_MIN_WIDTH; window.set_expanded(expanded); window.set_layout_class(if expanded { "expanded" } else { "compact" }.into()); } #[cfg(test)] mod tests { use super::*; fn meta() -> Metadata { Metadata { make: Some("Canon".into()), model: Some("Canon EOS 6D".into()), shutter: Some(1.0 / 250.0), aperture: Some(2.8), iso: Some(400), focal_length: Some(50.0), ..Default::default() } } #[test] fn camera_does_not_repeat_the_make() { // rawler reports make "Canon" and model "Canon EOS 6D"; naive // concatenation gives "Canon Canon EOS 6D". assert_eq!(describe_camera(&meta()), "Canon EOS 6D"); } #[test] fn camera_joins_when_model_omits_the_make() { let m = Metadata { make: Some("NIKON".into()), model: Some("D850".into()), ..Default::default() }; assert_eq!(describe_camera(&m), "NIKON D850"); } #[test] fn missing_camera_metadata_is_empty_not_a_placeholder() { assert_eq!(describe_camera(&Metadata::default()), ""); } #[test] fn shutter_reads_as_a_fraction_below_one_second() { assert!(describe_exposure(&meta()).starts_with("1/250")); } #[test] fn long_exposures_read_as_seconds() { let m = Metadata { shutter: Some(2.5), ..Default::default() }; assert_eq!(describe_exposure(&m), "2.5s"); } #[test] fn exposure_omits_absent_fields() { let m = Metadata { iso: Some(100), ..Default::default() }; assert_eq!(describe_exposure(&m), "ISO 100"); assert_eq!(describe_exposure(&Metadata::default()), ""); } #[test] fn only_supported_extensions_are_collected() { assert!(is_supported(Path::new("a.CR2"))); assert!(is_supported(Path::new("a.jpg"))); assert!(!is_supported(Path::new("a.txt"))); assert!(!is_supported(Path::new("noextension"))); } }