Files
DarkRoom/ui/dr-ui/src/lib.rs
T
dtourolle 2ca1716a29 Make "Choose folder" an actual folder picker
It previously fetched the folder list and threw it away into a status
line — a button that looked like it worked and did not. Now it opens a
browsable picker: click a folder to descend, ".." to go back, "Use this
folder" to select, "Cancel" to leave the root unchanged.

Descends one level per click because that is what the backend supports:
Depth: infinity is frequently disabled server-side and prohibitively
expensive where it is not (ARCH §8.4).

The chosen root persists immediately on confirm, so it survives a crash
before the library is opened. Confirming at the account root is allowed —
a user may legitimately keep everything at the top level — and cancelling
leaves any previous selection untouched, which a test asserts.

Verified against nextcloud.tourolle.paris at both depths: 30 folders at
the root, 21 year-folders inside PhotosRaw.

19 launch tests, 38 in dr-ui.
2026-08-09 15:50:40 +02:00

614 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<DevelopSession>,
fallback: Option<slint::Image>,
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<Loaded, String> {
// 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::<slint::Rgba8Pixel>::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<PathBuf> {
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<PathBuf> = 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<slint::VecModel<ParamRow>>,
session: &Rc<RefCell<Option<DevelopSession>>>,
) {
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<PathBuf>) -> 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<RefCell<Option<DevelopSession>>> = 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<slint::VecModel<ParamRow>> = 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<dyn Fn(&AppWindow)> = {
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::<ParamRow>::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")));
}
}