//! Slint interface for DarkRoom. //! //! v0.1 is a viewer: open a folder of RAW files, extract embedded previews, //! display them. //! //! **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. use std::cell::RefCell; use std::path::{Path, PathBuf}; use std::rc::Rc; use anyhow::Result; use dr_decode::{Metadata, PreviewSize}; 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 { image: slint::Image, meta: Metadata, width: u32, height: u32, } /// Load and decode one image for display. /// /// Reads the whole file because rawler needs the full container to locate a /// preview. The remote path (FR-NC-3) fetches only a byte range, which is why /// the decode API takes bytes rather than a reader. fn load(path: &Path) -> Result { let bytes = std::fs::read(path).map_err(|e| e.to_string())?; let meta = dr_decode::metadata(&bytes).unwrap_or_default(); let mut preview = dr_decode::extract_preview(&bytes, PreviewSize::Screen).map_err(|e| e.to_string())?; // Bound memory before handing pixels to the UI. preview.downscale_to(MAX_DISPLAY_DIM); let buffer = slint::SharedPixelBuffer::::clone_from_slice( &preview.rgba, preview.width, preview.height, ); Ok(Loaded { image: 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 } fn is_supported(p: &Path) -> bool { p.extension() .map(|e| e.to_string_lossy().to_ascii_lowercase()) .and_then(|e| dr_types::Format::from_extension(&e)) .is_some() } /// 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()?; // Report the GPU even though v0.1 displays through the CPU path: the // adapter is what spike S1 exercises, and showing it makes vendor // differences obvious during that work. 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()); } Err(e) => { log::warn!("no GPU adapter: {e}"); window.set_backend("NO GPU".into()); } } window.set_total(entries.len() as i32); let index = Rc::new(RefCell::new(0usize)); let show = { let entries = entries.clone(); let index = index.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(path) { Ok(l) => { window.set_canvas(l.image); 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()); 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}"); window.set_load_error(e.into()); window.set_camera("".into()); window.set_exposure("".into()); window.set_dimensions("".into()); } } }) }; { 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); }); } // 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"))); } }