Initial workspace: GPU context, compute pass, adaptive Slint shell

Establishes the v0.1 foundations on both platforms:

- dr-types: SourceRef (never a filesystem path — Android SAF has none),
  Format, Availability, Validator with ETag quote normalisation
- dr-gpu: wgpu device, compute pass writing a storage texture, resize
- dr-ui: Slint shell with FR-UI-1 adaptive layout, computed in Rust to
  avoid a binding loop
- docker/android: pinned toolchain, verified producing API 28 ARM binaries

Measured the cost of the temporary CPU readback path (dr-gpu bench):
compute is 0.06-0.28ms across sizes while readback is 0.63-7.43ms, so
readback is 90-96% of frame time and scales with area. Recorded in
ARCH §6.1 — this is why spike S1 is the priority.

Mitigations pending S1: reuse the staging buffer, apply at most one
resize per frame, and cap render resolution at 2048 on the long edge.

10 tests passing; core crates cross-compile for aarch64-linux-android.
This commit is contained in:
2026-08-09 07:42:05 +02:00
commit 82a5e21ec6
25 changed files with 10707 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
{
"permissions": {
"allow": [
"Bash(curl -s \"https://api.github.com/search/code?q=WrapTexture+org:Noesis\" -H \"Accept: application/vnd.github+json\")",
"Bash(curl -s \"https://api.github.com/orgs/Noesis/repos?per_page=100\")",
"WebFetch(domain:wiki.wxwidgets.org)",
"Bash(curl -sS \"https://gitlab.com/sciter-engine/sciter-js-sdk/-/raw/main/samples.gpu/hello-es-triangle.htm\")",
"Bash(curl -sS \"https://gitlab.com/api/v4/projects/sciter-engine%2Fsciter-js-sdk/repository/tree?path=include&recursive=true&per_page=100&ref=main\")",
"Bash(python3 -c ' *)",
"Bash(curl -sL --max-time 40 \"https://api.github.com/repos/wxWidgets/wxWidgets/contents/src?ref=master\")",
"Bash(curl -sL --max-time 40 \"https://api.github.com/repos/wxWidgets/wxWidgets/contents/include/wx/android?ref=master\")",
"Bash(curl -sL --max-time 40 -H \"Accept: application/vnd.github.text-match+json\" \"https://api.github.com/search/code?q=vulkan+repo:wxWidgets/wxWidgets\")",
"Bash(curl -sS \"https://gitlab.com/sciter-engine/sciter-js-sdk/-/raw/main/include/sciter-x-video-api.h\")",
"WebFetch(domain:docs.wxwidgets.org)",
"Bash(curl -sS \"https://gitlab.com/sciter-engine/sciter-js-sdk/-/raw/main/CHANGELOG.md\")",
"Bash(curl -sL --max-time 40 \"https://raw.githubusercontent.com/wxWidgets/wxWidgets/master/docs/readme.txt\")",
"Bash(curl -sL --max-time 40 \"https://raw.githubusercontent.com/wxWidgets/wxWidgets/master/docs/licence.txt\")",
"Bash(curl -sL --max-time 40 \"https://raw.githubusercontent.com/wxWidgets/wxWidgets/master/docs/licendu.txt\")",
"Bash(curl -sS \"https://gitlab.com/api/v4/projects/sciter-engine%2Fsciter-js-sdk/repository/tree?path=build&recursive=true&per_page=100&ref=main\")",
"Bash(curl -sS \"https://gitlab.com/sciter-engine/sciter-js-sdk/-/raw/main/premake5.lua\")",
"WebFetch(domain:slack-chats.kotlinlang.org)",
"Bash(curl -sS -L \"https://sciter.com/\")",
"Bash(curl -sL --max-time 45 \"https://api.github.com/orgs/ultralight-ux/repos?per_page=100&sort=pushed\")",
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/GNOME/gtk/-/raw/main/gdk/gdkdmabuftexturebuilder.h\")",
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/GNOME/gtk/-/raw/main/gdk/gdkgltexturebuilder.h\")",
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/api/v4/projects/GNOME%2Fgtk/repository/tree?path=gdk&ref=main&per_page=100\")",
"WebFetch(domain:docs.slint.dev)",
"WebFetch(domain:releases.slint.dev)",
"WebFetch(domain:flutter.dev)",
"Bash(curl -sS -L \"https://sciter.com/forums/topic/status-of-quark-sciter-lite-sciterjs-android-ios/\")",
"Bash(curl -sL --max-time 45 \"https://api.github.com/repos/ultralight-ux/AppCore/git/trees/master?recursive=1\")",
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/GNOME/gtk/-/raw/main/gdk/meson.build\")",
"WebFetch(domain:www.jetbrains.com)",
"Bash(curl -sS \"https://gitlab.com/api/v4/projects/sciter-engine%2Fsciter-js-sdk/repository/tree?path=demos.lite&recursive=true&per_page=100&ref=main\")",
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/api/v4/projects/GNOME%2Fgtk/repository/commits?path=gdk/android/gdkandroidglcontext.c&ref_name=main&per_page=20\")",
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/GNOME/gtk/-/raw/main/gdk/android/meson.build\")",
"WebFetch(domain:docs.sciter.com)",
"Bash(curl -sS -L \"https://sciter.com/support-of-displayflex-and-displaygrid-in-sciter/\")"
]
}
}
+4
View File
@@ -0,0 +1,4 @@
/target
/target-android
Cargo.lock.bak
*.log
Generated
+6562
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
[workspace]
resolver = "2"
members = [
"core/dr-types",
"core/dr-gpu",
"ui/dr-ui",
"apps/darkroom-desktop",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
rust-version = "1.92"
license = "GPL-3.0-or-later"
repository = "https://github.com/dtourolle/DarkRoom"
[workspace.dependencies]
# Internal
dr-types = { path = "core/dr-types" }
dr-gpu = { path = "core/dr-gpu" }
dr-ui = { path = "ui/dr-ui" }
# GPU + UI
wgpu = "23"
slint = { version = "1.9", default-features = false }
slint-build = "1.9"
# Foundations
anyhow = "1"
thiserror = "2"
log = "0.4"
env_logger = "0.11"
pollster = "0.4"
bytemuck = { version = "1", features = ["derive"] }
[profile.dev]
# Dependencies optimised even in dev builds — wgpu and image decoding are
# unusably slow otherwise, and they rarely need debugging.
opt-level = 0
[profile.dev.package."*"]
opt-level = 2
[profile.release]
lto = "thin"
codegen-units = 1
+48
View File
@@ -0,0 +1,48 @@
# DarkRoom
A cross-platform, non-destructive RAW photo editor for Linux and Android.
**Status:** early. v0.1 is a remote library viewer — see
[docs/milestone-v0.1.md](docs/milestone-v0.1.md).
## Documentation
| Document | Contents |
|---|---|
| [requirements.md](docs/requirements.md) | What the software must do — 122 numbered requirements |
| [architecture.md](docs/architecture.md) | How it is built — crates, GPU pipeline, data model, sync |
| [milestone-v0.1.md](docs/milestone-v0.1.md) | The first buildable milestone |
## Building
Desktop:
```bash
cargo run -p darkroom-desktop
```
Android (containerised toolchain, see [docker/android](docker/android/README.md)):
```bash
./docker/android/build.sh cargo ndk -t arm64-v8a build --release
```
## Current state
Working: workspace, GPU context and compute pass, adaptive Slint shell, Android
cross-compilation of the core crates.
**Not yet working:** the zero-copy display path. The build currently uploads
frames through the CPU, which is exactly what
[ARCH §6.1](docs/architecture.md) forbids — measured at 96% of frame time at
4K. Replacing it is spike S1, the project's highest priority.
```
cargo run -p dr-gpu --example bench --features readback
```
reproduces that measurement.
## Licence
GPL-3.0-or-later.
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "darkroom-desktop"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
dr-ui.workspace = true
anyhow.workspace = true
env_logger.workspace = true
log.workspace = true
[features]
default = ["readback"]
# Temporary CPU upload path until spike S1 lands zero-copy adoption.
readback = ["dr-ui/readback"]
+11
View File
@@ -0,0 +1,11 @@
//! DarkRoom desktop entry point.
fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(
env_logger::Env::default().default_filter_or("info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn"),
)
.init();
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
dr_ui::run()
}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "dr-gpu"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
dr-types.workspace = true
wgpu.workspace = true
thiserror.workspace = true
log.workspace = true
bytemuck.workspace = true
[dev-dependencies]
pollster.workspace = true
env_logger.workspace = true
[[example]]
name = "bench"
required-features = ["readback"]
[features]
default = []
# Exposes read_pixels outside tests. Production must not enable this.
readback = []
+33
View File
@@ -0,0 +1,33 @@
// Measures the per-frame cost at several canvas sizes, isolating the readback
// path from windowing.
use dr_gpu::{GpuContext, RenderTarget};
use std::time::Instant;
fn main() {
env_logger::init();
let ctx = pollster::block_on(GpuContext::new_headless()).unwrap();
println!("adapter: {}\n", ctx.adapter_name());
println!("{:>12} {:>10} {:>10} {:>8}", "size", "compute", "+readback", "fps");
for &(w, h) in &[(840u32, 692u32), (1280, 720), (1920, 1080), (2048, 1152), (3840, 2160)] {
let rt = RenderTarget::new(&ctx, w, h).unwrap();
// warm
for i in 0..10 { rt.render(i as f32 * 0.01); let _ = pollster::block_on(rt.read_pixels()); }
let n = 40;
let t0 = Instant::now();
for i in 0..n { rt.render(i as f32 * 0.01); }
ctx.device.poll(wgpu::Maintain::Wait);
let compute = t0.elapsed().as_secs_f64() / n as f64;
let t1 = Instant::now();
for i in 0..n {
rt.render(i as f32 * 0.01);
let _ = pollster::block_on(rt.read_pixels()).unwrap();
}
let full = t1.elapsed().as_secs_f64() / n as f64;
println!("{:>5}x{:<6} {:>8.2}ms {:>8.2}ms {:>8.0}",
w, h, compute * 1000.0, full * 1000.0, 1.0 / full);
}
}
+23
View File
@@ -0,0 +1,23 @@
/// Failures from the GPU layer.
///
/// `DeviceLost` is deliberately a distinct variant rather than folded into a
/// generic error: it is an expected event on Android (ARCH §6.10), not an
/// exceptional one, and callers recover from it by rebuilding the device and
/// re-driving from the edit graph.
#[derive(Debug, thiserror::Error)]
pub enum GpuError {
#[error("no suitable GPU adapter found")]
NoAdapter,
#[error("failed to request device: {0}")]
DeviceRequest(String),
#[error("GPU device lost — recreate and re-render from the edit graph")]
DeviceLost,
#[error("shader compilation failed: {0}")]
ShaderCompilation(String),
#[error("readback failed: {0}")]
Readback(String),
}
+494
View File
@@ -0,0 +1,494 @@
//! GPU device and compute for DarkRoom.
//!
//! In v0.1 this exists to prove one thing: a compute shader can write a
//! texture that reaches the screen without a CPU round-trip (ARCH §6.1). It
//! holds no pipeline, no tiling, and no masks — those arrive in v0.2.
//!
//! Deliberately free of UI dependencies (ARCH §6.5a). The texture is handed
//! out as a `wgpu::Texture`; who composites it is not this crate's concern.
use std::sync::Arc;
use wgpu::util::DeviceExt;
mod error;
pub use error::GpuError;
/// Owns the wgpu device and queue.
///
/// One device is shared by the compute pipeline and the UI, which is what
/// allows compositing with no interop layer. Cloning is cheap and shares the
/// same underlying device.
#[derive(Clone)]
pub struct GpuContext {
pub device: Arc<wgpu::Device>,
pub queue: Arc<wgpu::Queue>,
adapter_info: wgpu::AdapterInfo,
}
impl GpuContext {
/// Create a headless context — no surface, no window.
///
/// Used by tests and by the Slint path, which supplies its own surface.
pub async fn new_headless() -> Result<Self, GpuError> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
// Vulkan on both targets (D1). GL is allowed as a fallback so a
// machine without a Vulkan loader still runs the tests.
backends: wgpu::Backends::VULKAN | wgpu::Backends::GL,
..Default::default()
});
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: None,
force_fallback_adapter: false,
})
.await
.ok_or(GpuError::NoAdapter)?;
let adapter_info = adapter.get_info();
log::info!(
"gpu: {} ({:?}, {:?})",
adapter_info.name,
adapter_info.device_type,
adapter_info.backend
);
let (device, queue) = adapter
.request_device(
&wgpu::DeviceDescriptor {
label: Some("darkroom-device"),
required_features: wgpu::Features::empty(),
// Defaults, not `downlevel_defaults`: storage textures
// in compute shaders are required, and the downlevel tier
// does not guarantee them. This is effectively our GPU
// floor (NFR-COMPAT-1).
required_limits: wgpu::Limits::default(),
memory_hints: wgpu::MemoryHints::Performance,
},
None,
)
.await
.map_err(|e| GpuError::DeviceRequest(e.to_string()))?;
Ok(Self {
device: Arc::new(device),
queue: Arc::new(queue),
adapter_info,
})
}
/// Build a context from a device and queue owned by someone else — the
/// path used when Slint has already created them.
pub fn from_parts(
device: Arc<wgpu::Device>,
queue: Arc<wgpu::Queue>,
adapter_info: wgpu::AdapterInfo,
) -> Self {
Self {
device,
queue,
adapter_info,
}
}
pub fn adapter_name(&self) -> &str {
&self.adapter_info.name
}
pub fn backend(&self) -> wgpu::Backend {
self.adapter_info.backend
}
}
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
width: u32,
height: u32,
phase: f32,
_pad: f32,
}
/// A compute pass writing into a storage texture.
///
/// Stands in for the develop pipeline in v0.1. What matters is the shape:
/// compute writes a texture, the texture is handed to the compositor, and
/// pixels never travel back through the CPU.
pub struct RenderTarget {
ctx: GpuContext,
texture: wgpu::Texture,
view: wgpu::TextureView,
pipeline: wgpu::ComputePipeline,
bind_group_layout: wgpu::BindGroupLayout,
bind_group: wgpu::BindGroup,
params_buf: wgpu::Buffer,
width: u32,
height: u32,
/// Reused staging buffer for the temporary readback path. Allocating one
/// per frame is a significant cost at large window sizes.
#[cfg(any(test, feature = "readback"))]
readback_buf: std::cell::RefCell<Option<(wgpu::Buffer, u32)>>,
}
impl RenderTarget {
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
pub fn new(ctx: &GpuContext, width: u32, height: u32) -> Result<Self, GpuError> {
let (width, height) = (width.max(1), height.max(1));
let shader = ctx
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("gradient"),
source: wgpu::ShaderSource::Wgsl(
include_str!("shaders/gradient.wgsl").into(),
),
});
let bind_group_layout =
ctx.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("render-target-bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly,
format: Self::FORMAT,
view_dimension: wgpu::TextureViewDimension::D2,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let layout = ctx
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("render-target-layout"),
bind_group_layouts: &[&bind_group_layout],
push_constant_ranges: &[],
});
let pipeline = ctx
.device
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("gradient-pipeline"),
layout: Some(&layout),
module: &shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
let params_buf = ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("params"),
contents: bytemuck::bytes_of(&Params {
width,
height,
phase: 0.0,
_pad: 0.0,
}),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let (texture, view) = Self::create_texture(ctx, width, height);
let bind_group = Self::create_bind_group(ctx, &bind_group_layout, &view, &params_buf);
Ok(Self {
ctx: ctx.clone(),
texture,
view,
pipeline,
bind_group_layout,
bind_group,
params_buf,
width,
height,
#[cfg(any(test, feature = "readback"))]
readback_buf: std::cell::RefCell::new(None),
})
}
fn create_texture(
ctx: &GpuContext,
width: u32,
height: u32,
) -> (wgpu::Texture, wgpu::TextureView) {
let texture = ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some("render-target"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: Self::FORMAT,
// STORAGE_BINDING to write from compute; TEXTURE_BINDING so the
// compositor can sample it. COPY_SRC exists only for tests —
// production never reads this back (ARCH §6.1).
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
(texture, view)
}
fn create_bind_group(
ctx: &GpuContext,
layout: &wgpu::BindGroupLayout,
view: &wgpu::TextureView,
params: &wgpu::Buffer,
) -> wgpu::BindGroup {
ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("render-target-bg"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: params.as_entire_binding(),
},
],
})
}
/// Resize, reallocating the texture. No-op when unchanged.
pub fn resize(&mut self, width: u32, height: u32) {
let (width, height) = (width.max(1), height.max(1));
if width == self.width && height == self.height {
return;
}
let (texture, view) = Self::create_texture(&self.ctx, width, height);
self.bind_group = Self::create_bind_group(
&self.ctx,
&self.bind_group_layout,
&view,
&self.params_buf,
);
self.texture = texture;
self.view = view;
self.width = width;
self.height = height;
#[cfg(any(test, feature = "readback"))]
{
// Size changed, so the staging buffer no longer fits.
*self.readback_buf.borrow_mut() = None;
}
}
/// Run the compute pass. Results stay on the GPU.
pub fn render(&self, phase: f32) {
self.ctx.queue.write_buffer(
&self.params_buf,
0,
bytemuck::bytes_of(&Params {
width: self.width,
height: self.height,
phase,
_pad: 0.0,
}),
);
let mut enc = self
.ctx
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("render-encoder"),
});
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("gradient-pass"),
timestamp_writes: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.bind_group, &[]);
// 8x8 workgroups, rounded up so edge pixels are covered.
pass.dispatch_workgroups(self.width.div_ceil(8), self.height.div_ceil(8), 1);
}
self.ctx.queue.submit(Some(enc.finish()));
}
pub fn texture(&self) -> &wgpu::Texture {
&self.texture
}
pub fn view(&self) -> &wgpu::TextureView {
&self.view
}
pub fn size(&self) -> (u32, u32) {
(self.width, self.height)
}
/// Read pixels back to the CPU.
///
/// **Tests only.** Production code must never call this — it is exactly
/// the round-trip ARCH §6.1 forbids, and AC-8 asserts it does not happen.
#[cfg(any(test, feature = "readback"))]
pub async fn read_pixels(&self) -> Result<Vec<u8>, GpuError> {
// Buffer rows must be aligned to COPY_BYTES_PER_ROW_ALIGNMENT (256).
let unpadded = self.width * 4;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let padded = unpadded.div_ceil(align) * align;
let needed = (padded * self.height) as u64;
let mut slot = self.readback_buf.borrow_mut();
if slot.as_ref().map(|(_, p)| *p) != Some(padded) {
*slot = Some((
self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: needed,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
}),
padded,
));
}
let buf = &slot.as_ref().unwrap().0;
let mut enc = self
.ctx
.device
.create_command_encoder(&Default::default());
enc.copy_texture_to_buffer(
wgpu::ImageCopyTexture {
texture: &self.texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::ImageCopyBuffer {
buffer: buf,
layout: wgpu::ImageDataLayout {
offset: 0,
bytes_per_row: Some(padded),
rows_per_image: Some(self.height),
},
},
wgpu::Extent3d {
width: self.width,
height: self.height,
depth_or_array_layers: 1,
},
);
self.ctx.queue.submit(Some(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
self.ctx.device.poll(wgpu::Maintain::Wait);
rx.recv()
.map_err(|e| GpuError::Readback(e.to_string()))?
.map_err(|e| GpuError::Readback(e.to_string()))?;
// Strip row padding.
let data = slice.get_mapped_range();
let mut out = Vec::with_capacity((unpadded * self.height) as usize);
for row in 0..self.height {
let start = (row * padded) as usize;
out.extend_from_slice(&data[start..start + unpadded as usize]);
}
drop(data);
buf.unmap();
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx() -> Option<GpuContext> {
// CI runners and headless machines may have no usable adapter. Skip
// rather than fail — the device-dependent assertions still run
// wherever a GPU exists.
match pollster::block_on(GpuContext::new_headless()) {
Ok(c) => Some(c),
Err(e) => {
eprintln!("skipping: no GPU adapter ({e})");
None
}
}
}
#[test]
fn compute_writes_the_texture() {
let Some(ctx) = ctx() else { return };
let rt = RenderTarget::new(&ctx, 64, 64).expect("render target");
rt.render(0.0);
let px = pollster::block_on(rt.read_pixels()).expect("readback");
assert_eq!(px.len(), 64 * 64 * 4);
// The shader writes opaque pixels everywhere; an all-zero buffer would
// mean the dispatch silently did nothing.
assert!(
px.chunks_exact(4).all(|p| p[3] == 255),
"every pixel should be opaque"
);
assert!(
px.iter().any(|&b| b != 0),
"texture should not be uniformly zero"
);
}
#[test]
fn phase_changes_output() {
let Some(ctx) = ctx() else { return };
let rt = RenderTarget::new(&ctx, 32, 32).expect("render target");
rt.render(0.0);
let a = pollster::block_on(rt.read_pixels()).expect("readback");
rt.render(std::f32::consts::PI);
let b = pollster::block_on(rt.read_pixels()).expect("readback");
assert_ne!(a, b, "moving the highlight should change the image");
}
#[test]
fn resize_reallocates() {
let Some(ctx) = ctx() else { return };
let mut rt = RenderTarget::new(&ctx, 16, 16).expect("render target");
assert_eq!(rt.size(), (16, 16));
rt.resize(48, 24);
assert_eq!(rt.size(), (48, 24));
rt.render(0.0);
let px = pollster::block_on(rt.read_pixels()).expect("readback");
assert_eq!(px.len(), 48 * 24 * 4);
}
#[test]
fn zero_size_is_clamped() {
let Some(ctx) = ctx() else { return };
// A minimised window reports zero; texture creation would panic.
let rt = RenderTarget::new(&ctx, 0, 0).expect("render target");
assert_eq!(rt.size(), (1, 1));
}
}
+40
View File
@@ -0,0 +1,40 @@
// Placeholder compute shader — stands in for the develop pipeline.
//
// Its only job is to prove the path: a compute shader writes a storage
// texture, and that texture reaches the screen without a CPU round-trip
// (ARCH §6.1). Replaced by real pipeline stages in v0.2.
struct Params {
width: u32,
height: u32,
// Animates so it is visually obvious the compute pass runs every frame
// rather than a stale texture being redisplayed.
phase: f32,
_pad: f32,
}
@group(0) @binding(0) var output: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(1) var<uniform> params: Params;
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
if (gid.x >= params.width || gid.y >= params.height) {
return;
}
let uv = vec2<f32>(
f32(gid.x) / f32(params.width),
f32(gid.y) / f32(params.height),
);
// Warm dark ground with a moving highlight — deliberately unlike a test
// pattern, so a stuck frame is obvious at a glance.
let d = distance(uv, vec2<f32>(0.5 + 0.25 * cos(params.phase), 0.5 + 0.25 * sin(params.phase)));
let glow = 1.0 - smoothstep(0.0, 0.55, d);
let base = vec3<f32>(0.08, 0.07, 0.06);
let accent = vec3<f32>(0.69, 0.23, 0.15);
let rgb = base + accent * glow * (0.35 + 0.65 * uv.y);
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(rgb, 1.0));
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "dr-types"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
thiserror.workspace = true
+223
View File
@@ -0,0 +1,223 @@
//! Shared vocabulary for DarkRoom.
//!
//! Deliberately dependency-light: no platform code, no UI, no GPU. Everything
//! else in `core/` builds on these types, so anything added here is paid for
//! everywhere.
use std::fmt;
use std::ops::Range;
/// Identifies a granted library location — a directory on Linux, a persisted
/// document tree on Android.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct RootId(pub u64);
/// Identifies an image within a catalog.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ImageId(pub u64);
/// Identifies one edit variant of an image (a virtual copy).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct VersionId(pub u64);
/// An opaque, re-resolvable reference to source image data.
///
/// **Never a filesystem path.** Android's Storage Access Framework provides no
/// usable path (ARCH §6.9), so a `Path`-based API would not be portable. This
/// is the type that keeps `core/` platform-agnostic.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum SourceRef {
/// Desktop: a path relative to a granted root.
Local { root: RootId, relative: String },
/// Android: a SAF document URI under a persisted tree grant.
Document { tree: RootId, document_id: String },
/// Remote: resolved through the sync layer; may support range reads only.
Remote { file_id: u64, path: String },
}
impl SourceRef {
/// A stable, display-friendly name — the last path component.
pub fn display_name(&self) -> &str {
let full = match self {
SourceRef::Local { relative, .. } => relative.as_str(),
SourceRef::Document { document_id, .. } => document_id.as_str(),
SourceRef::Remote { path, .. } => path.as_str(),
};
// SAF document ids use ':' as a separator; paths use '/'. Split on
// whichever appears last so both yield a sensible name.
full.rsplit(['/', ':']).next().unwrap_or(full)
}
/// Lowercase file extension, if any.
pub fn extension(&self) -> Option<String> {
let name = self.display_name();
let (_, ext) = name.rsplit_once('.')?;
if ext.is_empty() {
None
} else {
Some(ext.to_ascii_lowercase())
}
}
}
impl fmt::Display for SourceRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.display_name())
}
}
/// Image formats recognised at the catalog level.
///
/// Recognition is by extension only; whether a decoder can actually handle the
/// file is a separate question answered by `dr-decode`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Format {
Cr2,
Cr3,
Nef,
Arw,
Raf,
Rw2,
Orf,
Dng,
Jpeg,
}
impl Format {
/// Recognise from a lowercase extension.
pub fn from_extension(ext: &str) -> Option<Self> {
Some(match ext {
"cr2" => Format::Cr2,
"cr3" => Format::Cr3,
"nef" => Format::Nef,
"arw" => Format::Arw,
"raf" => Format::Raf,
"rw2" => Format::Rw2,
"orf" => Format::Orf,
"dng" => Format::Dng,
"jpg" | "jpeg" => Format::Jpeg,
_ => return None,
})
}
/// Whether this is a camera RAW format needing demosaic.
pub fn is_raw(self) -> bool {
!matches!(self, Format::Jpeg)
}
}
/// How much of an image is available locally (FR-NC-6c).
///
/// Surfaced in the UI so a user always knows what they have — the failure
/// Lightroom makes by showing an original's filename beside a 2560px proxy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Availability {
/// Catalogued, nothing cached.
MetadataOnly,
/// A preview is cached; enough to browse, not to export.
Preview,
/// Full source available locally.
Original,
/// Source unreachable — drive unplugged, permission revoked, offline.
Offline,
}
/// An opaque change-validator for a remote entry (an ETag, or an mtime where
/// no ETag exists).
///
/// Quoting is inconsistent across servers, so comparison normalises rather
/// than testing raw equality — otherwise spurious full rescans result.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Validator(String);
impl Validator {
pub fn new(raw: impl Into<String>) -> Self {
let raw: String = raw.into();
let trimmed = raw.trim().trim_start_matches("W/").trim_matches('"');
Validator(trimmed.to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
/// A byte range request against a source.
pub type ByteRange = Range<u64>;
/// Errors from resolving or reading a source.
#[derive(Debug, thiserror::Error)]
pub enum SourceError {
#[error("source is offline or unreachable")]
Offline,
#[error("permission denied or revoked")]
PermissionDenied,
#[error("source not found")]
NotFound,
#[error("range requests unsupported by this source")]
RangeUnsupported,
#[error("io error: {0}")]
Io(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_name_handles_paths_and_saf_ids() {
let local = SourceRef::Local {
root: RootId(1),
relative: "2026/08/IMG_0042.CR3".into(),
};
assert_eq!(local.display_name(), "IMG_0042.CR3");
// SAF document ids are colon-separated, not slash-separated.
let saf = SourceRef::Document {
tree: RootId(1),
document_id: "primary:DCIM/Camera/IMG_0042.CR3".into(),
};
assert_eq!(saf.display_name(), "IMG_0042.CR3");
}
#[test]
fn extension_is_lowercased() {
let s = SourceRef::Local {
root: RootId(1),
relative: "IMG.CR3".into(),
};
assert_eq!(s.extension().as_deref(), Some("cr3"));
}
#[test]
fn extension_absent_when_no_dot() {
let s = SourceRef::Local {
root: RootId(1),
relative: "NOEXTENSION".into(),
};
assert_eq!(s.extension(), None);
}
#[test]
fn formats_round_trip_and_classify() {
assert_eq!(Format::from_extension("cr3"), Some(Format::Cr3));
assert_eq!(Format::from_extension("jpeg"), Some(Format::Jpeg));
assert_eq!(Format::from_extension("txt"), None);
assert!(Format::Cr3.is_raw());
assert!(!Format::Jpeg.is_raw());
}
#[test]
fn validator_normalises_quoting() {
// Nextcloud quotes ETags; some servers add a weak prefix. All three
// spellings must compare equal or every sync looks like a change.
assert_eq!(Validator::new("\"abc123\""), Validator::new("abc123"));
assert_eq!(Validator::new("W/\"abc123\""), Validator::new("abc123"));
}
#[test]
fn availability_orders_by_completeness() {
assert!(Availability::Original > Availability::Preview);
assert!(Availability::Preview > Availability::MetadataOnly);
}
}
+120
View File
@@ -0,0 +1,120 @@
# DarkRoom — reproducible Android build environment
#
# Pins the entire toolchain: JDK, Android SDK, NDK, Rust, and the four Android
# targets. Both CI and local builds use this image, so "works on my machine"
# and "works in CI" are the same machine.
#
# Build: podman build -t darkroom-android:latest docker/android
# Use: ./docker/android/build.sh cargo ndk -t arm64-v8a build --release
FROM docker.io/library/debian:bookworm-slim
# ---------------------------------------------------------------------------
# Versions — pinned deliberately. Bumping any of these is a reviewable change,
# not something that drifts underneath the build.
# ---------------------------------------------------------------------------
ARG JDK_VERSION=17
# Compile SDK / target API.
ARG ANDROID_API=36
# Minimum supported API — the level native code links against (NFR-COMPAT-1).
# 28 (Android 9) matches the floor where Vulkan support is dependable.
# cargo-ndk otherwise defaults to 21, which is far below what this app needs.
ARG MIN_API=28
ARG BUILD_TOOLS=36.0.0
ARG NDK_VERSION=27.2.12479018
ARG CMDLINE_TOOLS=13114758
# Must satisfy cargo-ndk's MSRV (4.1.x needs >= 1.86) as well as our own crates.
ARG RUST_VERSION=1.92.0
# JDK 17, not the host's 25: the Android Gradle Plugin supports 17 as its
# stable target, and newer JDKs regularly break Gradle in ways that cost more
# time than they save.
ENV DEBIAN_FRONTEND=noninteractive \
ANDROID_HOME=/opt/android-sdk \
ANDROID_SDK_ROOT=/opt/android-sdk \
JAVA_HOME=/usr/lib/jvm/java-${JDK_VERSION}-openjdk-amd64 \
CARGO_HOME=/opt/cargo \
RUSTUP_HOME=/opt/rustup \
PATH=/opt/cargo/bin:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:$PATH
# ---------------------------------------------------------------------------
# System packages
# ---------------------------------------------------------------------------
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl unzip git \
openjdk-${JDK_VERSION}-jdk-headless \
# Slint / winit build-time needs
pkg-config libfontconfig1-dev \
# native deps that may need building for host-side tooling
build-essential cmake python3 \
&& rm -rf /var/lib/apt/lists/*
# ---------------------------------------------------------------------------
# Android SDK + NDK
# ---------------------------------------------------------------------------
RUN mkdir -p ${ANDROID_HOME}/cmdline-tools \
&& curl -fsSL -o /tmp/tools.zip \
"https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip" \
&& unzip -q /tmp/tools.zip -d ${ANDROID_HOME}/cmdline-tools \
&& mv ${ANDROID_HOME}/cmdline-tools/cmdline-tools ${ANDROID_HOME}/cmdline-tools/latest \
&& rm /tmp/tools.zip
RUN yes | sdkmanager --licenses > /dev/null 2>&1 || true \
&& sdkmanager --install \
"platform-tools" \
"platforms;android-${ANDROID_API}" \
"build-tools;${BUILD_TOOLS}" \
"ndk;${NDK_VERSION}" \
> /dev/null
ENV ANDROID_NDK_HOME=${ANDROID_HOME}/ndk/${NDK_VERSION} \
ANDROID_NDK_ROOT=${ANDROID_HOME}/ndk/${NDK_VERSION}
# ---------------------------------------------------------------------------
# Rust + Android targets
#
# All four ABIs. arm64-v8a covers essentially every current device; the others
# exist so an ABI-specific build break is caught here rather than at release.
# ---------------------------------------------------------------------------
RUN curl -fsSL https://sh.rustup.rs | sh -s -- \
-y --no-modify-path --profile minimal --default-toolchain ${RUST_VERSION} \
&& rustup target add \
aarch64-linux-android \
armv7-linux-androideabi \
x86_64-linux-android \
i686-linux-android \
&& rustup component add rustfmt clippy \
&& cargo install cargo-ndk --locked \
&& chmod -R a+rwX ${CARGO_HOME} ${RUSTUP_HOME}
# ---------------------------------------------------------------------------
# Linker configuration
#
# cargo-ndk normally handles this, but setting it explicitly means plain
# `cargo build --target …` works too, which matters for tooling that shells
# out to cargo directly (rust-analyzer, cargo-metadata).
# ---------------------------------------------------------------------------
ENV NDK_BIN=${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin
# Linkers target MIN_API, not ANDROID_API — the binary must run on the oldest
# supported device, while the SDK compiles against the newest.
ENV CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=${NDK_BIN}/aarch64-linux-android${MIN_API}-clang \
CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER=${NDK_BIN}/armv7a-linux-androideabi${MIN_API}-clang \
CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER=${NDK_BIN}/x86_64-linux-android${MIN_API}-clang \
CARGO_TARGET_I686_LINUX_ANDROID_LINKER=${NDK_BIN}/i686-linux-android${MIN_API}-clang
# cargo-ndk reads this; without it it defaults to API 21.
ENV CARGO_NDK_PLATFORM=${MIN_API} \
ANDROID_PLATFORM=${MIN_API}
# Shared cargo registry cache — bind-mount over this to persist across runs.
VOLUME ["/opt/cargo/registry"]
WORKDIR /work
# Rootless podman maps the host user into the container, so the image must not
# assume a fixed uid. Keep world-writable toolchain dirs and let the caller
# pass --user.
RUN chmod -R a+rwX ${ANDROID_HOME}
CMD ["/bin/bash"]
+73
View File
@@ -0,0 +1,73 @@
# Android build environment
Reproducible container for cross-compiling DarkRoom to Android. CI and local builds use the same
image, so a break appears in one place rather than two.
## Use
```bash
# Cross-compile for a device ABI
./docker/android/build.sh cargo ndk -t arm64-v8a build --release
# All four ABIs
./docker/android/build.sh cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -t x86 build --release
# Type-check without linking
./docker/android/build.sh cargo check --target aarch64-linux-android
# Interactive shell
./docker/android/build.sh
# After editing the Dockerfile
./docker/android/build.sh --rebuild
```
Prefers `podman`, falls back to `docker`. First run builds the image, which takes several minutes;
after that it is cached.
## Pinned versions
| Component | Version | Why this one |
|---|---|---|
| JDK | 17 | AGP's stable target. The host's JDK 25 breaks Gradle. |
| Compile SDK | API 36 | Required for Play distribution |
| **Min API** | **28** (Android 9) | Floor where Vulkan support is dependable (NFR-COMPAT-1) |
| Build tools | 36.0.0 | Matches compile SDK |
| NDK | 27.2.12479018 (r27c) | Current LTS |
| Rust | 1.92.0 | Slint 1.17 requires it; cargo-ndk 4.1.x needs ≥ 1.86 |
| cargo-ndk | 4.1.2 | |
Bumping any of these is a reviewable change, not silent drift.
### Compile SDK versus min API
These are different and both matter. `ANDROID_API` (36) is what the SDK compiles against — the
newest APIs available. `MIN_API` (28) is what native code *links* against, and determines the
oldest device that can run the result.
cargo-ndk defaults to **API 21** if not told otherwise, which is far below what this app needs.
`CARGO_NDK_PLATFORM` and the per-target linker paths both pin it to `MIN_API`. Verify with:
```bash
file target/aarch64-linux-android/release/*.so
# → "... for Android 28, built by NDK r27c"
```
## Caching
The cargo registry and target directory persist in `~/.cache/darkroom-android`, so rebuilds do not
re-download the crate index. Clear with:
```bash
rm -rf ~/.cache/darkroom-android
```
The Android target dir is kept separate from the host's `target/` — sharing one directory between
host and container builds causes constant rebuilds as they invalidate each other's fingerprints.
## What is not here
No emulator, no `adb` device access. This image cross-compiles; it does not run or deploy. Device
testing (spikes S2 and S10, which need two GPU vendors) happens on real hardware — the emulator's
GPU behaviour is not representative of Adreno or Mali, which is precisely what those spikes exist
to test.
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Run a command inside the DarkRoom Android build container.
#
# ./docker/android/build.sh cargo ndk -t arm64-v8a build --release
# ./docker/android/build.sh cargo check --target aarch64-linux-android
# ./docker/android/build.sh # interactive shell
#
# Builds the image on first use. Rebuild after editing the Dockerfile with:
# ./docker/android/build.sh --rebuild
set -euo pipefail
IMAGE="darkroom-android:latest"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "${HERE}/../.." && pwd)"
# Prefer podman (rootless by default); fall back to docker.
if command -v podman >/dev/null 2>&1; then
ENGINE=podman
elif command -v docker >/dev/null 2>&1; then
ENGINE=docker
else
echo "error: neither podman nor docker found" >&2
exit 1
fi
if [[ "${1:-}" == "--rebuild" ]]; then
shift
"${ENGINE}" build -t "${IMAGE}" "${HERE}"
elif ! "${ENGINE}" image exists "${IMAGE}" 2>/dev/null && \
! "${ENGINE}" image inspect "${IMAGE}" >/dev/null 2>&1; then
echo "==> building ${IMAGE} (first run; several minutes)"
"${ENGINE}" build -t "${IMAGE}" "${HERE}"
fi
# Persist the cargo registry and target dir across runs, or every build
# re-downloads the crate index.
CACHE="${XDG_CACHE_HOME:-${HOME}/.cache}/darkroom-android"
mkdir -p "${CACHE}/registry" "${CACHE}/target"
ARGS=(
--rm
-v "${REPO}:/work:z"
-v "${CACHE}/registry:/opt/cargo/registry:z"
-v "${CACHE}/target:/work/target-android:z"
-e CARGO_TARGET_DIR=/work/target-android
-w /work
)
# Rootless podman already maps the host user; docker needs it stated.
if [[ "${ENGINE}" == "docker" ]]; then
ARGS+=(--user "$(id -u):$(id -g)")
fi
if [[ $# -eq 0 ]]; then
ARGS+=(-it)
set -- /bin/bash
fi
exec "${ENGINE}" run "${ARGS[@]}" "${IMAGE}" "$@"
+946
View File
@@ -0,0 +1,946 @@
# DarkRoom — Architecture
**Status:** Draft v0.1 · 2026-08-08
**Companion to:** [requirements.md](requirements.md)
How DarkRoom is built. The requirements document says *what* the software must do; this says *how*,
and records the decisions and constraints behind the design.
---
## 1. Overview
DarkRoom is a Rust application with a Slint interface, rendering through wgpu to Vulkan on both
Linux and Android. The design is organised around four ideas, each of which the rest of this
document elaborates:
1. **Pixels stay on the GPU.** From decode to display, image data never round-trips through the
CPU. This is the constraint that most shapes the codebase (§6.1).
2. **Sidecars are authoritative; the catalog is a rebuildable index.** Durability comes from plain
text files next to the images, not from a database (§6.12).
3. **Operations describe themselves.** The develop pipeline emits parameter descriptors as data;
the UI generates controls from them. The core never depends on the UI toolkit (§4.3, §6.5a).
4. **Everything is tiled and cancellable.** Work is decomposed into tiles scheduled by visibility,
so interaction always preempts background work (§5.3).
### 1.1 Stack
| Layer | Choice | Decision |
|---|---|---|
| Language | Rust | D1 |
| UI | Slint | D1, D8 |
| GPU | wgpu → Vulkan (Linux + Android) | D1 |
| Shaders | Hand-written WGSL | D6 |
| RAW decode | rawler; LibRaw fallback behind a trait | D2 |
| Catalog | SQLite (WAL) — a rebuildable index | D5, §6.12 |
| Colour | lcms2 + GPU-side matrix/LUT transforms | D5 |
| Network | reqwest + quick-xml | D7 |
| Licence | GPLv3 | D8 |
---
## 2. Crate layout
Dependency direction encodes the layering rule: nothing in `core/` may depend on anything in `ui/`.
```
darkroom/
├── core/
│ ├── dr-types SourceRef, ImageId, VersionId, ParamValue — shared vocabulary
│ ├── dr-catalog SQLite index, scan, query, metadata
│ ├── dr-sidecar the authoritative edit store (§6.12)
│ ├── dr-decode RawDecoder trait, rawler impl, embedded-preview extraction
│ ├── dr-pipeline Operation trait, descriptors, edit graph, registry
│ ├── dr-gpu wgpu device, tile scheduler, WGSL shaders, mask rasteriser
│ ├── dr-colour lcms2 bindings, camera profiles, working-space transforms
│ ├── dr-export encoders, resampling, output sizing
│ ├── dr-sync RemoteBackend trait, sync engine, cache rules, merge
│ └── dr-sync-nextcloud the only backend implementation (§8.4)
├── ui/
│ ├── dr-ui Slint components, adaptive layout, descriptor→control mapping
│ └── dr-widgets custom controls per WidgetKind (curve, wheel, crop, brush)
├── platform/
│ ├── dr-plat trait definitions: storage, secrets, lifecycle, power
│ ├── dr-plat-linux XDG dirs, Secret Service, filesystem
│ └── dr-plat-android SAF, Keystore, WorkManager, lifecycle
└── apps/
├── darkroom-desktop
└── darkroom-android
```
**CI asserts the dependency rule.** A UI dependency appearing in any `core/` crate's tree fails the
build. This is easy to violate accidentally — one `use slint::` undoes headless testability and the
one-operation-two-presentations property together.
`platform/` exists so `core/` contains no `#[cfg(target_os)]`. Platform traits are defined in
`dr-plat` and injected at construction by the app crates.
---
## 3. Core abstractions
### 3.1 SourceRef — addressing image data
Android's Storage Access Framework provides no filesystem path (§6.9), so **no core API takes a
`Path`**.
```rust
/// An opaque, re-resolvable reference to source image data.
pub struct SourceRef(SourceRefInner);
enum SourceRefInner {
/// Desktop: an absolute path under a granted root.
Path { root: RootId, relative: PathBuf },
/// Android: a SAF document URI under a persisted tree grant.
Document { tree: RootId, document_id: String },
/// Remote: resolved through the sync layer, possibly byte-range only.
Remote { file_id: u64, path: String },
}
impl SourceRef {
/// Open a seekable stream. May fail if the source is offline (FR-CAT-9).
pub fn open(&self, plat: &dyn Storage) -> Result<Box<dyn SeekableRead>, SourceError>;
/// Read a byte range without opening the whole source — the fast path for
/// embedded preview extraction (FR-CULL-2) and remote browsing (FR-NC-3).
pub fn read_range(&self, plat: &dyn Storage, r: Range<u64>) -> Result<Vec<u8>, SourceError>;
}
```
`read_range` is deliberately first-class rather than a convenience over `open`. Extracting an
embedded JPEG preview from a remote 80 MB RAW must transfer ~1–3 MB, and the culling preview ladder
depends on the same primitive locally.
### 3.2 RawDecoder
```rust
pub trait RawDecoder: Send + Sync {
fn probe(&self, header: &[u8]) -> Option<Format>;
fn embedded_preview(&self, src: &mut dyn SeekableRead) -> Result<Option<Preview>, DecodeError>;
fn decode(&self, src: &mut dyn SeekableRead) -> Result<RawImage, DecodeError>;
fn metadata(&self, src: &mut dyn SeekableRead) -> Result<Metadata, DecodeError>;
}
```
Four separate entry points because the caller's needs differ sharply by phase. Culling wants
`embedded_preview` and nothing else; the grid wants `metadata`; only develop and export need
`decode`. Fusing them would force full decode where a 200 KB range read suffices.
`RawImage` carries CFA-pattern sensor data plus black/white levels and camera colour matrices — it
is *not* demosaiced. Demosaic is a GPU pipeline stage (§5.2).
### 3.3 Operation and descriptors
The develop pipeline is a sequence of operations with a uniform interface. Polymorphism is by
trait, never inheritance — there is no shared implementation to inherit, only a shared shape.
```rust
pub trait Operation: Send + Sync {
/// Static parameter description. Drives UI generation (FR-DEV-3a).
fn descriptor() -> OpDescriptor where Self: Sized;
/// Encode GPU work for one tile. No UI types cross this boundary.
fn encode(&self, enc: &mut ComputeEncoder, ctx: &TileContext);
/// Identity for cache invalidation. Integer state only — exactly
/// deterministic, unlike GPU float output (§6.13).
fn params_hash(&self) -> u64;
/// What this operation's parameters affect, for invalidation scoping.
fn affects(&self) -> Affects;
}
```
Descriptors are pure data:
```rust
pub struct ParamDescriptor {
pub id: ParamId,
pub label: LocalizedKey, // a key, not a string — core has no localiser
pub kind: ParamKind,
pub default: ParamValue,
pub affects: Affects,
}
pub enum ParamKind {
Scalar { min: f32, max: f32, scale: Scale, unit: Unit, precision: u8 },
Bool,
Enum { variants: Vec<(EnumId, LocalizedKey)> },
Colour { has_alpha: bool },
/// Controls that don't reduce to primitives. Core names the *kind*;
/// dr-widgets owns the implementation.
Custom { widget: WidgetKind, schema: CustomParamSchema },
}
```
`LocalizedKey` rather than a resolved string is what lets NFR-A11Y-1 work without `core/` depending
on a localisation library that pulls in UI concerns. The UI resolves keys against its catalogue.
### 3.4 Edit graph
```rust
pub struct EditGraph {
version: VersionId,
ops: Vec<OpInstance>, // ordered; order is data, not code
masks: Vec<MaskDef>,
}
pub struct OpInstance {
kind: OpKind,
enabled: bool,
params: BTreeMap<ParamId, ParamValue>,
mask: Option<MaskId>, // None = global
}
```
`BTreeMap` rather than `HashMap` so serialisation is deterministic — the sidecar format is diffable
and the graph hash is stable across runs.
The graph is **CPU-side state**, which is what makes GPU device-loss recovery tractable (§6.10): the
device can be destroyed and rebuilt, and the render re-driven from the graph with nothing lost.
---
## 4. Layering
### 4.1 The four layers
```
┌──────────────────────────────────────────────────────┐
│ apps/ wiring, platform injection │
├──────────────────────────────────────────────────────┤
│ ui/ Slint; descriptor → control mapping │
│ owns: presentation, gestures, layout │
├──────────────────────────────────────────────────────┤
│ core/ catalog, pipeline, sync, export │
│ owns: all behaviour and state │
├──────────────────────────────────────────────────────┤
│ platform/ storage, secrets, lifecycle, power │
└──────────────────────────────────────────────────────┘
```
Calls go downward. `core/` reaches `platform/` through traits; `ui/` reaches `core/` through a
session API. Nothing calls upward — the UI observes change through subscriptions, not callbacks
registered into the core.
### 4.2 The session API
`ui/` sees a small surface, not the internals:
```rust
pub struct App { /* … */ }
impl App {
pub fn catalog(&self) -> &Catalog;
pub fn open_develop(&self, v: VersionId) -> DevelopSession;
pub fn open_cull(&self, filter: Filter) -> CullSession;
pub fn export(&self, sel: &[VersionId], preset: &ExportPreset) -> JobHandle;
pub fn sync(&self) -> &SyncEngine;
/// Change notifications. The UI subscribes; the core never holds UI callbacks.
pub fn subscribe(&self) -> Receiver<CoreEvent>;
}
pub struct DevelopSession { /* … */ }
impl DevelopSession {
pub fn descriptors(&self) -> &[OpDescriptor]; // drives panel generation
pub fn set_param(&mut self, op: OpId, p: ParamId, v: ParamValue);
pub fn set_viewport(&mut self, vp: Viewport);
/// The rendered result as a GPU texture handle. Never pixels.
pub fn texture(&self) -> TextureHandle;
pub fn histogram(&self) -> &HistogramBuffer; // GPU reduction, not readback
pub fn undo(&mut self); pub fn redo(&mut self);
}
```
`texture()` returning a handle rather than pixel data is the API-level expression of §6.1.
### 4.3 Descriptor → control mapping
`dr-ui` maps `ParamKind` to a control by input modality (FR-DEV-3b). The mapping table lives in the
UI; the core is unaware presentation varies.
| `ParamKind` | Pointer | Touch |
|---|---|---|
| `Scalar` | Slider + numeric entry, wheel fine-adjust | Drag-strip, double-tap reset |
| `Bool` | Checkbox | Switch, ≥44pt |
| `Enum` | Dropdown | Segmented control or sheet |
| `Colour` | Swatch → popover | Swatch → sheet |
| `Custom` | `dr-widgets` control | Same control, touch hit-targets |
Adding an operation therefore requires no UI change (FR-DEV-3c) unless it needs a new `WidgetKind`.
---
## 5. GPU architecture
### 5.1 Device and surface
One `wgpu::Device` shared by the pipeline and Slint, so compute output composites without an
interop layer. Slint's `create_texture_from_hal` imports the texture directly.
**Spike S1 validates this**, and it is the project's highest-risk assumption. If it fails, D1's
recorded fallbacks apply.
### 5.2 Pipeline stages
Ordered; each consumes and produces GPU textures. Order is data (§3.4), so operations can be
reordered without code changes.
```
RawImage (sensor data, CPU)
│ upload
▼
┌─────────────────────┐
│ black/white levels │ integer normalise
├─────────────────────┤
│ demosaic │ Bayer or Markesteijn (X-Trans, FR-RAW-5)
├─────────────────────┤
│ AI denoise │ optional; raw-domain, joint with demosaic where possible
├─────────────────────┤
│ camera profile │ matrices + per-body base curve (FR-DEV-3e)
├─────────────────────┤
│ → working space │ linear, wide-gamut, f16
├─────────────────────┤
│ white balance │
│ exposure/contrast │
│ highlights/shadows │ ← masks apply per-op from here down
│ tone curve │
│ HSL / colour mixer │
│ texture / clarity │
│ spot removal │
│ sharpen / NR │
│ lens corrections │
│ look (HaldCLUT) │ FR-DEV-3f
├─────────────────────┤
│ geometry │ crop, straighten, rotate
├─────────────────────┤
│ output transform │ → display or export profile
└─────────────────────┘
│
├─→ display texture (composited by Slint — never read back)
└─→ histogram reduction (compute → small buffer, §5.5)
```
Working precision is f16 in a linear wide-gamut space, quantising once at the output transform.
### 5.3 Tiling and scheduling
Work decomposes into tiles (default 256×256) scheduled by priority class:
| Class | Work | Preempts |
|---|---|---|
| `Interactive` | Visible tiles at current viewport | everything |
| `Prefetch` | Tiles just outside viewport; next cull image | Background |
| `Background` | Export, thumbnail generation, proxy building | — |
`Interactive` **strictly preempts** `Background`. Without this, moving a slider during a batch
export misses its frame budget — the common case, not an edge case.
Tile results cache keyed by `(VersionId, tile, zoom, graph_hash_prefix)`, where the prefix covers
operations up to the first `Affects` change. Adjusting exposure reuses cached demosaic and camera
profile output for every tile.
### 5.4 Mask rasterisation
**All masks rasterise on the GPU, including drawn brush strokes** (§6.11). Strokes arrive as
parameters — points, radius, hardness, flow — and a compute shader rasterises them. The mask never
exists in CPU memory.
This is a direct response to darktable, where drawn masks are CPU-rasterised and users consistently
describe brush lag as making them "unworkable". The problem is architectural, not performance
tuning, and not fixable with a faster CPU.
### 5.5 Histogram without readback
The histogram is a compute-shader reduction into a small storage buffer, read once per frame at
most, and only the *bins* — never image data. A per-frame CPU readback of pixels would reintroduce
exactly the stall §6.1 exists to prevent.
Raw-domain histograms for culling (FR-CULL-3) reduce over the pre-demosaic texture, which is why
they can report headroom the embedded JPEG's histogram cannot.
### 5.6 Device loss
Handled as expected, not exceptional (§6.10):
1. Detect `wgpu::SurfaceError::Lost` or device-lost callback
2. Discard all GPU-side state — textures, buffers, pipelines
3. Recreate device, recompile from the shader cache
4. Re-drive the current render from the edit graph
No user edit is lost, because the graph is CPU-side. Shader pipelines cache on disk keyed by device,
driver version, and shader hash (NFR-P12).
---
## 6. Data architecture
### 6.1 Sidecars are authoritative
The durability model inverts the usual arrangement (§6.12):
```
image.CR3 ← never written to
image.CR3.drsc ← authoritative edit state, plain text
(or app-managed store where the location is read-only)
catalog.sqlite ← index; deletable and rebuildable at any time
```
Sidecar format is versioned plain text holding **all** Versions for an image:
```toml
schema = 1
image_hash = "blake3:..."
[versions.default]
name = "Default"
created = 2026-08-08T14:02:00Z
revision = 7
device = "…"
[versions.default.ops.exposure]
enabled = true
exposure = 0.35
contrast = 12.0
```
Writing discipline, each point addressing a documented failure elsewhere:
- **Atomic** — temp plus rename, never partial
- **Debounced** — not per slider tick
- **Only on real content change** — darktable rewrites sidecars without edits, breaking backup
deduplication and mtime-based sync
- **Never embedded in the RAW** — would force re-backup of the whole file
### 6.2 The catalog as index
SQLite in WAL mode, holding what is needed for interactive query over 50k images and nothing
authoritative:
```sql
roots(id, kind, grant_blob, label, last_seen)
images(id, root_id, source_ref, content_hash, format, w, h,
captured_at, camera, lens, iso, aperture, shutter,
availability, sidecar_mtime)
versions(id, image_id, name, is_default, graph_hash, rating, label, flag)
keywords(version_id, keyword)
folders(id, root_id, path, etag, parent_id) -- ETag pruning (§6.6)
remote(image_id, file_id, etag, sync_state, remote_path)
cache(version_id, kind, resolution, graph_hash, path, bytes, last_used)
jobs(id, kind, state, payload, attempts) -- resumable across process death
```
`folders.etag` must exist from schema v1 — sync pruning cannot work without persisted folder ETags,
and adding it later means a migration plus a full re-scan of every library (§6.6).
Rebuild reads sidecars and re-derives everything else. This makes NFR-R6's recovery path the normal
mechanism rather than a last resort.
### 6.3 Identity
| Entity | Key | Stable across |
|---|---|---|
| Image | content hash | rename, move, re-import |
| Version | UUID | everything |
| Remote file | `oc:fileid` | server-side rename/move |
| Cache entry | `(version, kind, resolution, graph_hash)` | — |
Content-hash identity is what makes FR-CAT-9's reconnection work: a moved file is recognised rather
than re-imported, and a server-side move is not a re-download of 80 MB.
---
## 7. Concurrency
### 7.1 Executors
| Executor | Threads | Work |
|---|---|---|
| UI | 1 | Slint event loop. **Never blocks.** |
| GPU submit | 1 | Command encoding and queue submission |
| Decode | cores − 2 | RAW decode, preview extraction |
| I/O | 4 | Catalog, sidecar, cache, filesystem |
| Network | 2 | Nextcloud transfer |
The UI executor never blocks — this is the mechanism behind R4 and NFR-P9, which the requirements
state as outcomes without saying how.
### 7.2 Cancellation
Cooperative, with tokens threaded through every long operation. Observed within 100 ms
(NFR-ARCH-3), including in-flight GPU submissions. Cancelling a scan, export, or sync leaves no
partial state beyond what is separately resumable.
### 7.3 Errors
No worker panics the process. Errors are typed and attach to the affected image or job:
```rust
pub enum ImageError { Decode(DecodeError), SourceOffline, Gpu(GpuError), … }
```
A decode failure marks one image and continues the batch (FR-RAW-4). A GPU error triggers §5.6
recovery. Panics in decode are caught at the boundary, since RAW parsing handles untrusted input.
---
## 8. Sync architecture
Sync is pluggable. `dr-sync` defines a `RemoteBackend` trait; **only the Nextcloud connector is
implemented**, but the boundary is designed so S3, generic WebDAV, or a self-hosted photo server can
be added without touching the sync engine.
### 8.1 Why capability negotiation, not a common denominator
The obvious abstraction — a trait exposing only what every backend can do — would be a mistake here,
and it is worth being explicit about why.
Nextcloud's fast path depends on a behaviour that is **not** a WebDAV guarantee: it propagates ETag
changes *up* the folder tree, so an unchanged root ETag proves nothing anywhere in the library
changed. That single property is what turns a 50k-image no-op sync into **one HTTP request**. A
plain WebDAV server (Apache `mod_dav`, an SFTP mount, a raw S3 bucket) offers no such guarantee, and
a trait built to their common subset would force full enumeration on every sync — the same 50k
requests the design exists to avoid.
So backends **declare capabilities**, and the sync engine picks the best available strategy:
```rust
pub struct Capabilities {
/// How the backend reports what changed. Determines sync cost.
pub change_detection: ChangeDetection,
/// Stable identity across server-side rename/move.
pub stable_ids: bool,
/// Byte-range reads — required for embedded-preview extraction.
pub range_reads: bool,
/// Resumable upload for large files.
pub chunked_upload: Option<ChunkConstraints>,
/// Many-small-files upload in one request (sidecars).
pub bulk_upload: bool,
/// Conditional write for optimistic concurrency.
pub conditional_write: bool,
/// Server-rendered thumbnails, if any.
pub server_previews: ServerPreviews,
}
pub enum ChangeDetection {
/// Backend hands us a cursor; we ask "what changed since?".
/// Cheapest possible. (No current backend, but the shape most
/// object stores and delta APIs fit.)
DeltaCursor,
/// Directory ETags propagate upward — unchanged parent proves
/// unchanged subtree. Nextcloud. One request for a no-op sync.
PropagatingEtags,
/// ETags exist but only on the entry itself. Must enumerate the
/// tree; ETags then avoid re-downloading unchanged content.
LocalEtags,
/// Nothing but modification times. Enumerate and compare.
Timestamps,
}
```
### 8.2 Strategy per capability tier
The engine has one strategy per `ChangeDetection` variant, chosen at connect time:
| Tier | Detection | No-op sync cost, 50k images | Notes |
|---|---|---|---|
| 1 | `DeltaCursor` | 1 request | Cursor persisted in `remote_state` |
| 2 | `PropagatingEtags` | **1 request** | Nextcloud. Root ETag unchanged → done |
| 3 | `LocalEtags` | ~1 per folder | Enumerate tree; ETags prevent re-download |
| 4 | `Timestamps` | ~1 per folder + clock-skew risk | Degraded; warn the user |
Tiers 3 and 4 are honest degradations, not silent ones — the UI reports the sync strategy in use so
a slow backend is visibly slow rather than mysteriously slow.
**Capability absence never breaks correctness, only speed** — with two exceptions the engine must
handle explicitly:
- **No `range_reads`** → embedded-preview extraction is impossible, so remote browsing must fall
back to server previews or full download. On a metered connection the engine refuses full
downloads for browsing and reports why.
- **No `conditional_write`** → sidecar conflict detection loses its guarantee. The engine falls back
to revision-counter comparison inside the sidecar body, which narrows but does not close the race.
This is reported as a reduced-safety mode.
### 8.3 The trait
```rust
#[async_trait]
pub trait RemoteBackend: Send + Sync {
fn capabilities(&self) -> &Capabilities;
fn connect(&mut self, auth: AuthHandle) -> Result<Identity, RemoteError>;
// ---- discovery -------------------------------------------------
/// One level. `since` carries a per-entry validator (ETag/mtime)
/// so unchanged entries can be skipped by the backend where possible.
async fn list(&self, dir: &RemotePath, since: Option<&Validator>)
-> Result<Vec<RemoteEntry>, RemoteError>;
/// Tier 1 only — capability-gated, returns Unsupported otherwise.
async fn delta(&self, cursor: &Cursor)
-> Result<(Vec<RemoteChange>, Cursor), RemoteError>;
/// Tier 2 only — cheap validator for a directory, without listing it.
async fn dir_validator(&self, dir: &RemotePath)
-> Result<Validator, RemoteError>;
// ---- transfer --------------------------------------------------
async fn get(&self, id: &RemoteId, range: Option<Range<u64>>)
-> Result<Bytes, RemoteError>;
async fn put(&self, path: &RemotePath, body: Body, precond: Option<Precondition>)
-> Result<Validator, RemoteError>;
async fn put_many(&self, items: Vec<(RemotePath, Bytes)>)
-> Result<Vec<Result<Validator, RemoteError>>, RemoteError>;
async fn delete(&self, id: &RemoteId, precond: Option<Precondition>)
-> Result<(), RemoteError>;
// ---- optional --------------------------------------------------
async fn thumbnail(&self, id: &RemoteId, size: u32)
-> Result<Option<Bytes>, RemoteError>;
}
```
Design notes worth keeping:
- **`get` takes an optional range** rather than having a separate `get_range`. Callers always
express what they need; backends without range support return the whole object and the engine
slices, so correctness holds while the capability flag tells the caller whether it was cheap.
- **`put` takes a `Precondition`**, not a bare ETag — so `IfMatch`, `IfNoneMatch`, and `None`
are all expressible, and a backend without conditional writes can reject at compile-time-visible
runtime rather than silently ignoring.
- **`put_many` is a required method** with a default implementation looping over `put`. Nextcloud
overrides it with bulk upload; other backends get correct behaviour for free.
- **Chunked upload is not in the trait.** It is an implementation detail of `put` — the backend
decides based on body size and its own constraints. Exposing it would leak Nextcloud's protocol
into the interface.
### 8.4 The Nextcloud connector
The only implementation. Mapping to the trait:
| Trait method | Nextcloud |
|---|---|
| `capabilities` | `PropagatingEtags`, stable ids, ranges, chunked (5MB–5GB), bulk, conditional |
| `list` | `PROPFIND Depth:1` with `oc:fileid`, `getetag`, `nc:has-preview` |
| `dir_validator` | `PROPFIND Depth:0` requesting `getetag` only |
| `delta` | `Unsupported` — no RFC 6578 for files (ARCH §6.6) |
| `get` + range | `GET` with `Range:`; detect support by `206` vs `200`, never `HEAD` |
| `put` large | Chunked v2: `MKCOL` upload dir → `PUT` chunks → `MOVE .file` |
| `put_many` | `POST /remote.php/dav/bulk`, `multipart/related` |
| `thumbnail` | `/core/preview?fileId=…&forceIcon=false` — `false` is mandatory, or a server that cannot render RAW returns a generic mimetype icon that we would cache as a thumbnail |
| auth | Login Flow v2, system browser, app password |
Pruning walk, the Tier 2 strategy in full:
```
dir_validator(root)
├─ unchanged vs stored → done. One request, whole library.
└─ changed → list(dir, since)
├─ child dirs whose validator differs → recurse
└─ files whose validator differs → queue transfer
```
`folders.etag` must exist from schema v1. Adding it later means a migration plus a full re-scan of
every user's library (ARCH §6.6).
### 8.5 Sidecar conflict resolution
`put` with `Precondition::IfMatch(validator)`. On precondition failure:
1. `get` the remote sidecar
2. **Merge per Version, per operation** — a crop on one device and an exposure change on another
both survive, because they touch disjoint operations
3. Genuinely conflicting operations resolve by device timestamp
4. Retry with the new validator, bounded retry count
No `(conflicted copy)` files. Sidecars are a few KB of structured data, so read-merge-rewrite is
cheap and preserves intent — a meaningful improvement over file-level conflict copies, which is what
the sidecar-authoritative model (ARCH §6.12) buys.
---
## 9. Caching and availability policy
Sync decides *what changed*. Caching policy decides *what is kept locally* — a separate concern, and
the one that determines whether the app is usable on a tablet with a 2 TB library behind it.
### 9.1 The three tiers, restated as policy
| Tier | Content | Default |
|---|---|---|
| Metadata | Catalog rows, sidecars, ratings | Always synced; kilobytes; even on metered |
| Preview | Embedded JPEGs, display proxies | LRU within a size cap |
| Original | Full RAW | Never by default — explicit pin or on-demand |
Never bulk-sync originals. A 2 TB library against a 128 GB tablet makes mirror-style sync unusable
(ARCH §6.8) — which is precisely where the official Nextcloud desktop client's model fails.
### 9.2 Cache rules
A **cache rule** pins a *set* of images at a *tier*. Rules are user-authored, evaluated against the
catalog, and re-evaluated as it changes — so "the last three months" stays current without the user
touching it.
```rust
pub struct CacheRule {
pub id: RuleId,
pub selector: Selector,
pub tier: Tier, // Preview | Original
pub priority: u8, // eviction order; higher survives longer
pub enabled: bool,
}
pub enum Selector {
Collection(CollectionId),
Folder { root: RootId, path: PathBuf, recursive: bool },
/// Absolute or rolling. `Rolling` re-evaluates daily.
DateRange(DateSelector),
Rating { min: u8 },
Label(ColourLabel),
Flag(FlagState),
Keyword(String),
/// Boolean composition, so "5-star OR flagged, in the last year" works.
All(Vec<Selector>),
Any(Vec<Selector>),
Not(Box<Selector>),
}
pub enum DateSelector {
Between { from: Date, to: Date },
/// "Last 90 days" — window moves with the clock.
Rolling { days: u32 },
/// "This trip" — bounded by a collection's own capture range.
CollectionSpan(CollectionId),
}
```
Worked examples of what this expresses:
| Intent | Rule |
|---|---|
| Trip on the tablet | `Collection(trip) → Original` |
| Recent work at full quality | `Rolling{90} → Original` |
| Portfolio always available | `Rating{min:5} → Original`, high priority |
| Everything browsable | `All → Preview` (the implicit default rule) |
| Client edit on the road | `All([Collection(job), Flag(Pick)]) → Original` |
### 9.3 Evaluation and eviction
Rules produce a **desired state** per image; the sync engine reconciles actual against desired:
```
for each image:
desired = max(tier of every matching enabled rule) // most generous wins
if desired > actual → enqueue fetch (priority = rule priority)
if desired < actual → mark evictable (not evicted immediately)
```
Eviction is lazy and only under pressure — cache cap reached (NFR-RES-4) or platform memory pressure
(FR-PLAT-AND-5). Order: unpinned originals by last-used, then proxies, then thumbnails. **Never
metadata or sidecars** — those are authoritative (ARCH §6.12) and tiny.
An image that leaves a rolling window is not deleted at midnight; it becomes the first candidate when
space is actually needed. Fetching is likewise opportunistic: queued at the rule's priority behind
anything interactive, and on Android gated by the unmetered-and-charging constraints in FR-NC-6.
### 9.4 Honesty about what is local
Adobe's sync fails on *legibility* more than transport: Lightroom Classic syncs only 2560px proxies
while displaying the original's filename, extension, and size, so users genuinely do not know what
they have. DarkRoom shall not repeat this.
- Every image carries a visible availability state: `Original` · `Preview` · `Metadata only` ·
`Offline`
- Operations needing data that is absent say so *before* starting, with the transfer size
- Export from a preview-only image is refused, not silently degraded
- A pinned set reports its true byte cost before the user commits to it
### 9.5 Storage
```sql
cache_rules(id, selector_json, tier, priority, enabled)
image_cache(image_id, tier_actual, tier_desired, bytes, last_used, pinned_by_rule)
```
`tier_desired` is materialised rather than recomputed per query, so the grid can render availability
badges without evaluating every rule for every visible cell.
---
## 10. Platform abstraction
```rust
pub trait Storage: Send + Sync {
fn roots(&self) -> Vec<RootId>;
fn enumerate(&self, root: RootId, cb: &mut dyn FnMut(SourceRef)) -> Result<(), StorageError>;
fn open(&self, src: &SourceRef) -> Result<Box<dyn SeekableRead>, StorageError>;
fn read_range(&self, src: &SourceRef, r: Range<u64>) -> Result<Vec<u8>, StorageError>;
fn writable_sidecar_dir(&self, src: &SourceRef) -> Option<PathBuf>;
}
pub trait Secrets: Send + Sync { /* Secret Service | Android Keystore */ }
pub trait Lifecycle: Send + Sync { /* process death, memory pressure, background */ }
```
| Concern | Linux | Android |
|---|---|---|
| Storage | Filesystem under granted roots | SAF tree URIs, `DocumentsContract` |
| Secrets | Secret Service (libsecret) | Keystore + DataStore + Tink |
| Background | Threads | WorkManager, foreground service |
| Lifecycle | Process lives | Death at any moment; `onTrimMemory` |
`writable_sidecar_dir` returns `None` on read-only mounts and most SAF trees, which is when the
app-managed sidecar store is used.
---
## 11. Build order
Independent of D12's resolution, the foundations are shared. What differs is what gets built on top
first.
**Phase 0 — spikes.** S1 (Slint+wgpu zero-copy), S2 (Android, two GPU vendors), S10 (SAF at 10k
files), S11 (Play permissions), S9 (cross-platform tolerance). These can invalidate the
architecture; everything else assumes they pass.
**Phase 1 — foundations, needed either way.** `dr-types`, `dr-plat` + both implementations,
`dr-catalog`, `dr-sidecar`, `dr-decode` (preview path first), `dr-gpu` device and tile scheduler.
**Phase 2 — depends on D12:**
- *Culler-first:* preview ladder, culling mode, raw histogram, focus peaking, ratings, burst
grouping. Ships something usable without any develop chain.
- *Vertical-slice-first:* minimal develop chain (exposure, curve), export, proving the full
architecture end to end but not yet useful.
**Phase 3+:** the remaining develop operations, masking, sync, ingest, AI denoise.
---
## 12. Architectural constraints
Decisions treated as fixed because reversing them is expensive. Each is evidence-backed.
### 6.1 GPU results never round-trip through the CPU
darktable identifies this as their single biggest bottleneck: OpenCL output returns to the GTK
thread, which paints it on one CPU core. It constrains the UI framework choice more than any other
requirement — whatever renders the UI must composite our GPU texture directly.
**Measured on this project** (Radeon RX 7900 XTX, `cargo run -p dr-gpu --example bench
--features readback`), comparing the compute pass alone against compute plus a CPU readback:
| Canvas | Compute | With readback | Readback share |
|---|---|---|---|
| 840×692 | 0.06 ms | 0.63 ms | 90% |
| 1920×1080 | 0.11 ms | 2.35 ms | 95% |
| 3840×2160 | 0.28 ms | 7.43 ms | **96%** |
At 4K the shader finishes in 0.28 ms and then 7.15 ms is spent moving pixels through the CPU — a
26× overhead that scales with area, which is why an uncapped window resize falls off a cliff. The
constraint is not a stylistic preference; it is the dominant cost in the frame.
### 6.2 Tiling from day one
Mobile GPUs have far less memory. Retrofitting tiling into a whole-image pipeline is a rewrite.
### 6.3 Non-destructive edit graph, GPU-resident
Recompute from the graph rather than caching CPU bitmaps between stages.
### 6.4 GPU-first, not GPU-accelerated
RawTherapee shows excellent quality is achievable on CPU — and that retrofitting GPU into a mature
CPU pipeline effectively never happens.
### 6.5 The catalog is the UI's source of truth for queries
The UI never touches storage or network synchronously.
### 6.5a The core has no UI dependency
Operations describe parameters as data. This buys headless golden-image testing, one-operation-two-
presentations, and UI replaceability. CI-enforced.
### 6.6 Sync cannot rely on server-side change tokens
**Verified:** Nextcloud's `Directory.php` does not implement `ISyncCollection`; sync tokens exist
only for CalDAV/CardDAV (`nextcloud/server#22584`, open since 2020). Folder ETags must be persisted
from schema v1.
### 6.7 Server-side RAW previews are not assumable
**Verified:** no RAW preview provider ships with Nextcloud. Range-based embedded extraction is the
primary path; server previews are opportunistic. `preview_max_filesize_image` defaults to 50 MB,
excluding many RAWs even where a provider exists.
### 6.8 Sync is selective, not mirror-style
### 6.9 Android forbids the filesystem-scan model
**Verified:** `MANAGE_EXTERNAL_STORAGE` is not grantable — Play policy explicitly disallows "media
files access" as a permitted use. `READ_MEDIA_IMAGES` would not help: proprietary RAW is not typed
`image/*` by the platform scanner, so it does not appear in `MediaStore.Images`. SAF is the only
path, and it provides no filesystem path — hence `SourceRef` (§3.1).
### 6.10 GPU device loss is expected
Routine on Android: backgrounding, driver resets, thermal events. Recovery is tractable because the
edit graph is CPU-side.
### 6.11 Drawn masks are GPU-rasterised
darktable rasterises parametric masks on GPU and drawn masks on CPU; users describe drawn masking as
"unworkable" and the lag is not fixable with more cores. Highest-value architectural win available
relative to the incumbents, and free if designed in now.
### 6.12 Sidecars authoritative, catalog disposable
darktable maintains both a database and sidecars while achieving the reliability of neither —
documented lockouts, corruption where all three recovery paths failed, sidecars silently not written
in 5.0.0. RawTherapee's `.pp3` is the model: plain text, next to the file, no database in the trust
path.
### 6.13 Bit-identity applies to integer state only
GPU float results diverge across vendors — transcendental implementations differ, drivers optimise
differently, f16 rounding varies. Cache keys and graph hashes are computed over CPU-side integer
state, which is exactly deterministic. Cross-platform *rendering* equality is a bounded tolerance
(R1), not a checksum.
---
## 13. Decisions
Full rationale in [requirements.md §8](requirements.md). Summary:
| # | Decision | Status |
|---|---|---|
| D1 | Rust + Slint + wgpu | Decided |
| D2 | rawler, LibRaw fallback behind a trait | Decided |
| D3 | First milestone | **Provisional — depends on D12** |
| D4 | Nextcloud: ETag pruning, chunked upload, Login Flow v2 | Resolved |
| D5 | lcms2 + GPU-side transforms | Decided |
| D6 | Hand-written WGSL | Decided |
| D7 | reqwest + quick-xml | Decided |
| D8 | GPLv3 | Decided |
| D9 | Declarative operation descriptors | Decided |
| D10 | Single adaptive interface | Decided |
| D11 | Product positioning | Decided |
| D12 | Scope versus pace | **Open** |
---
## 14. Open questions
**D12 — scope versus pace.** The selected feature set implies years of full-time work against a
stated evenings-and-weekends budget. Resolving it sets D3's milestone and §11's Phase 2.
**NFR-R8 — CPU fallback extent.** §6.4 says GPU-first; NFR-RES-2 assumes a CPU fallback on
allocation failure. Decide whether that means a full CPU pipeline (a second implementation) or only
tile-spill staging. Recommend the latter.
**Slint accessibility on Android.** Unverified; may be a toolkit gap (NFR-A11Y-2). Cheaper to
discover before the UI is built.
**Play Store versus GPLv3.** Generally workable but should be confirmed, with F-Droid as fallback.
+321
View File
@@ -0,0 +1,321 @@
# DarkRoom v0.1 — Remote library viewer
**Status:** Draft · 2026-08-08
**Companion to:** [requirements.md](requirements.md) · [architecture.md](architecture.md)
The first buildable milestone: connect to a Nextcloud folder, index it locally, and display RAW
previews on both Linux and Android.
---
## 1. What this is
A photo *viewer*, not an editor. It connects to a Nextcloud account, lets the user pick a folder,
indexes what is there into a local catalog, and displays images by extracting their embedded JPEG
previews over byte-range requests.
**It exists to prove the architecture is sound before anything is built on it.** Four assumptions
in the design would each be expensive to discover wrong later, and this milestone tests all four
against real servers, real files, and real devices.
### 1.1 Why this shape
The alternative first milestone (D3's vertical slice: local scan → grid → open → two edits →
export) proves the pipeline but is useful to nobody and tests nothing about sync or SAF. This
milestone is comparable in size, exercises the genuinely risky parts, and produces something you
can actually point at a server and use.
### 1.2 What it deliberately excludes
No editing. No develop pipeline, no sidecars, no export, no culling mode, no ratings. Those depend
on foundations this milestone establishes; adding them before the foundations are proven risks
building on sand.
---
## 2. The four assumptions under test
Each maps to a spike in [requirements.md §9](requirements.md).
| # | Assumption | If wrong | Spike |
|---|---|---|---|
| **A1** | Slint can composite a wgpu compute texture zero-copy, on both platforms | ARCH §6.1 fails; D1's fallbacks apply; **the framework choice is wrong** | S1, S2 |
| **A2** | Android SAF can enumerate and range-read at library scale | FR-CAT-1a and the Android performance targets fail | S10 |
| **A3** | HTTP Range extraction of embedded previews works against Nextcloud | Remote browsing on mobile data is not viable; ARCH §6.7 fails | S4 |
| **A4** | reqwest does TLS on Android without unmanageable pain | D7's escape hatch needed | S3 |
**A1 is the one that would hurt most.** It determines whether Rust + Slint was the right call at
all, so it should be proven in the first week, before catalog or sync work begins.
---
## 3. Functional scope
Requirement IDs reference [requirements.md](requirements.md); a v0.1 suffix marks a reduced subset
of the full requirement.
### 3.1 Account and connection
**M-1 — Login.** Connect to a Nextcloud instance via Login Flow v2 (FR-NC-1): POST to
`/index.php/login/v2`, open the returned URL in the **system browser**, poll until an app password
arrives. The app never handles the user's primary password.
`User-Agent` identifies the device so the resulting app password is revocable per-device.
**M-2 — Credential storage.** Store the app password in platform secure storage (FR-NC-2) — Secret
Service on Linux, Keystore-backed on Android. Never in the catalog, never in logs.
**M-3 — Folder selection.** Browse the remote tree and choose one folder as the library root.
Recursive descent is in scope; multiple roots are not.
**M-4 — Disconnect.** Revoke via `DELETE /ocs/v2.php/core/apppassword` and clear local credentials.
### 3.2 Indexing
**M-5 — Remote listing.** `PROPFIND Depth:1` walking the chosen folder recursively, requesting
`oc:fileid`, `getetag`, `getcontentlength`, `getlastmodified`, `resourcetype`, and
`nc:has-preview`. Files whose extension matches the supported set (§3.3) are catalogued; others are
ignored.
**M-6 — Catalog.** Persist to SQLite in WAL mode. v0.1 schema is a strict subset of ARCH §6.2:
```sql
schema_version(version)
accounts(id, server_url, login_name, user_id)
folders(id, account_id, parent_id, remote_path, etag, last_listed)
images(id, account_id, file_id, remote_path, etag, size, remote_mtime,
format, captured_at, camera, lens, iso, aperture, shutter,
width, height, availability)
previews(image_id, kind, width, height, bytes, path, last_used)
```
`folders.etag` is present **from schema v1** — ARCH §6.6 requires it, and retrofitting means a
migration plus a full re-scan of every library.
`schema_version` exists from the first commit so NFR-R5's migration machinery has somewhere to
start.
**M-7 — Incremental re-listing.** On subsequent syncs use ETag pruning (FR-NC-4): `PROPFIND
Depth:0` on the root, and if its ETag is unchanged, **stop** — one request proves the whole library
is unchanged. Where changed, `Depth:1` and recurse only into folders whose ETags differ.
This is the mechanism that has to work for the library to scale, so v0.1 exercises it deliberately
rather than always re-listing.
**M-8 — Offline browsing.** The catalog is queryable with no network. Previously indexed images
appear with their metadata and any cached previews. Availability is visible per image (FR-NC-6c):
*Preview* or *Metadata only*.
### 3.3 RAW handling
**M-9 — Formats.** The FR-RAW-1 launch set: CR2, CR3, NEF, ARW, RAF, RW2, ORF, DNG. Plus JPEG, so
a mixed folder displays sensibly.
**M-10 — Range-based preview extraction.** Display without downloading whole files:
1. Range-read the first 256 KB of the file
2. Parse the container to locate the embedded JPEG preview (offset and length)
3. Range-read exactly those bytes
4. Decode the JPEG and cache it locally
Typical cost 1–3 MB against 25–100 MB for the full file. **This is what makes the app usable on
mobile data**, and it is assumption A3.
Range support is detected by issuing a `Range` request and checking for `206` versus `200` —
**never** by probing with `HEAD`, since Nextcloud does not advertise `Accept-Ranges` (ARCH §6.7).
**M-11 — Fallbacks, in order.** Where step 2 finds no usable preview:
1. Server preview via `/core/preview?fileId=…&forceIcon=false` where `nc:has-preview` is true.
**`forceIcon=false` is mandatory** — the default returns a generic mimetype icon for files the
server cannot render, which would otherwise be cached as though it were a thumbnail.
2. Full download and decode via rawler, on explicit user action only, never automatically.
3. Placeholder with an explanatory state.
**M-12 — Metadata.** Extract from the same header range already fetched in M-10: camera make and
model, lens, capture time, ISO, aperture, shutter, dimensions. No second request.
### 3.4 Display
**M-13 — Grid.** Virtualised thumbnail grid (FR-CAT-4) rendering only visible cells plus a prefetch
margin, with bounded memory independent of folder size.
**M-14 — Single image view.** Full-window display of one image, with fit and 1:1 zoom, pan, and
next/previous navigation.
**M-15 — GPU display path.** Decoded previews upload to GPU textures and composite through Slint
via `create_texture_from_hal`. **Pixels are never read back to the CPU** (ARCH §6.1).
This is assumption A1, and the reason it is in v0.1 at all: it is far cheaper to discover a
compositing problem now than after a develop pipeline is written against it.
**M-16 — Adaptive layout.** Compact and expanded layout classes (FR-UI-1) driven by window size,
not device type. Touch targets meet 44pt when touch is the active modality (FR-UI-3).
### 3.5 Platform
**M-17 — Linux.** X11 and Wayland. XDG base directories for catalog, cache, and config. Secret
Service for credentials.
**M-18 — Android.** Storage Access Framework only (FR-PLAT-AND-1) — no `MANAGE_EXTERNAL_STORAGE`,
no `READ_MEDIA_IMAGES`. v0.1 reads remote content, so SAF matters for the *cache* location and for
proving the `SourceRef` abstraction holds before local library support arrives.
Keystore-backed credential storage. Process death mid-browse restores the current folder and scroll
position (FR-PLAT-AND-3).
---
## 4. Explicitly out of scope
Listed so absence reads as a decision.
| Excluded | Arrives in |
|---|---|
| Any editing, develop pipeline, sidecars | v0.2 |
| Export | v0.2 |
| Culling mode, ratings, flags, labels | v0.3 |
| Focus peaking, raw histogram | v0.3 |
| Local (non-Nextcloud) library scanning | v0.2 |
| Upload, bi-directional sync, conflict merge | v0.4 |
| Cache rules and pinning (FR-NC-6a) | v0.4 |
| Multiple accounts or roots | later |
| Ingest from card | later |
| Search and filter beyond folder navigation | v0.3 |
**Read-only against the server.** v0.1 performs no `PUT`, `MOVE`, or `DELETE` on remote content.
This removes conflict handling and chunked upload from scope entirely, and means a bug cannot
damage the user's library.
---
## 5. Acceptance criteria
Measured against a reference Nextcloud instance holding **≥5,000 RAW files**, on the reference
desktop and **two Android devices with different GPU vendors** (Adreno and Mali).
| # | Criterion | Target |
|---|---|---|
| **AC-1** | Login through to first grid render | < 30 s for 5,000 files |
| **AC-2** | Re-sync with nothing changed | **1 HTTP request** |
| **AC-3** | Grid scroll, cached previews | 60 fps sustained, both platforms |
| **AC-4** | Bytes transferred per image, preview path | < 3 MB average |
| **AC-5** | Single-image display from cache | < 100 ms |
| **AC-6** | Offline launch → browsable grid | < 2 s desktop, < 4 s Android |
| **AC-7** | Memory, 5,000-image folder open | < 400 MB desktop, < 200 MB Android |
| **AC-8** | GPU readback of image data | **Zero occurrences** — asserted by instrumentation |
| **AC-9** | Android process death mid-browse | Folder and scroll position restored |
| **AC-10** | Catalog after forced kill during sync | Opens clean, resumes |
**AC-2 and AC-8 are the load-bearing ones.** AC-2 proves ETag pruning works, which is what lets the
library scale. AC-8 proves ARCH §6.1 holds — and it is asserted by instrumentation rather than
inspection, because a readback introduced later would otherwise pass unnoticed until it showed up
as unexplained slowness.
---
## 6. Build order
### Phase 0 — de-risk (before anything else)
Throwaway code answering the four assumptions. If A1 fails, stop and revisit D1 rather than
building on it.
1. **A1 / S1** — wgpu compute writes a texture; Slint composites UI over it; Linux. Test on
Mesa/AMD, Intel, and NVIDIA proprietary, under X11 and Wayland.
2. **A1 / S2** — the same on both Android devices.
3. **A4 / S3** — reqwest HTTPS PROPFIND from an Android device, including the
`rustls-platform-verifier` Kotlin init.
4. **A3 / S4** — range-extract an embedded JPEG from a CR3, NEF, and ARW on a real server; measure
bytes.
5. **A2 / S10** — SAF enumeration and range reads over a 10k-file tree; measure against the
NFR-P1/P3 figures.
### Phase 1 — foundations
`dr-types` (`SourceRef`, ids) · `dr-plat` traits and both implementations · `dr-catalog` (v0.1
schema, migrations from commit one) · `dr-gpu` (device, texture upload, Slint bridge).
### Phase 2 — connectivity
`dr-sync` (`RemoteBackend` trait) · `dr-sync-nextcloud` (Login Flow v2, PROPFIND, ETag pruning,
range GET) · credential storage.
### Phase 3 — imaging
`dr-decode` (container parsing, embedded-preview location, JPEG decode, metadata) · preview cache.
### Phase 4 — interface
`dr-ui` (grid, single-image view, adaptive layout, folder picker, connection flow).
### Phase 5 — hardening
Offline behaviour · process death · cancellation · error surfaces · the AC suite in CI.
**Both platforms build in CI from the first commit.** This is the point of choosing both from day
one: an Android break is caught the day it lands, not at a porting milestone.
---
## 7. Crates in play
A subset of ARCH §2. Crates not listed are not created yet.
```
core/
dr-types ✓ SourceRef, ImageId, AccountId, Validator
dr-catalog ✓ v0.1 schema subset, queries, migrations
dr-decode ✓ preview extraction + metadata; no demosaic
dr-gpu ✓ device, texture upload, Slint bridge; no pipeline
dr-sync ✓ RemoteBackend trait, ETag pruning engine
dr-sync-nextcloud ✓ the connector
dr-pipeline ✗ v0.2
dr-colour ✗ v0.2
dr-export ✗ v0.2
dr-sidecar ✗ v0.2
ui/
dr-ui ✓ grid, viewer, connection flow
dr-widgets ✗ v0.2 (no custom controls yet)
platform/
dr-plat ✓ Storage, Secrets, Lifecycle traits
dr-plat-linux ✓
dr-plat-android ✓
apps/
darkroom-desktop ✓
darkroom-android ✓
```
`dr-gpu` exists in v0.1 **only** to upload decoded JPEGs and hand textures to Slint. No compute
pipeline, no tiling, no masks. It is deliberately the thinnest thing that still proves A1.
---
## 8. Decisions this milestone informs
| Decision | What v0.1 tells us |
|---|---|
| **D12** — scope vs pace | How long a milestone of this size actually takes at the available pace. The single most useful output. |
| **D3** — first milestone | Supersedes the vertical slice, if this proves the better shape. |
| **D1** — Rust + Slint | AC-8 and A1 either confirm the framework choice or reopen it. |
| **NFR-COMPAT-1** — hardware baseline | Two real Android devices give the baseline actual numbers instead of a placeholder. |
| **D7** — network stack | Whether the reqwest Android TLS path is a half-day or a fortnight. |
---
## 9. Risks
| Risk | Likelihood | Mitigation |
|---|---|---|
| Slint `create_texture_from_hal` doesn't work as documented | Medium | Phase 0 first; D1 records fallbacks |
| SAF enumeration too slow at 10k files | Medium | S10 measures before commitment; batch and cache aggressively |
| Embedded previews too small or absent on some bodies | High | Known — Sony embeds small previews, some bodies none. M-11's fallback chain handles it; detect per camera model |
| reqwest Android TLS worse than expected | Medium | D7 escape hatch: `tls_certs_only` with `webpki-roots` |
| GPU vendor divergence on Android | Medium | Two vendors in CI from the start |
| Scope creeps toward editing | **High** | §4 is explicit; v0.1 is read-only against the server |
The last one is the real risk. A viewer that works is a strong temptation to add "just one slider."
+1142
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "dr-ui"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
dr-types.workspace = true
dr-gpu.workspace = true
slint = { workspace = true, features = ["compat-1-2", "renderer-femtovg", "backend-winit"] }
wgpu.workspace = true
anyhow.workspace = true
log.workspace = true
pollster.workspace = true
[build-dependencies]
slint-build.workspace = true
[features]
default = []
# Temporary CPU readback path; see dr-ui docs and spike S1.
readback = ["dr-gpu/readback"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
slint_build::compile("ui/app.slint").expect("compiling app.slint");
}
+248
View File
@@ -0,0 +1,248 @@
//! Slint interface for DarkRoom.
//!
//! v0.1 exists to validate assumption A1: compute output reaching the screen
//! without a CPU round-trip (ARCH §6.1).
//!
//! **Current state — read this before assuming A1 is proven.** Slint's public
//! API for adopting an externally-created wgpu texture is not yet wired up
//! here; this build uploads through `SharedPixelBuffer`, which *is* a CPU
//! round-trip. It is correct and cross-platform, but it is explicitly the
//! thing the architecture forbids in production.
//!
//! Spike S1 replaces this with the zero-copy path. Until it does, `A1` is
//! unvalidated and the `readback` feature makes the temporary path visible
//! rather than silent.
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Instant;
use anyhow::Result;
use dr_gpu::{GpuContext, RenderTarget};
slint::include_modules!();
/// Frame timing, averaged over a short window so the readout is stable enough
/// to read.
struct FrameClock {
last: Instant,
accum: f32,
frames: u32,
fps: i32,
start: Instant,
}
impl FrameClock {
fn new() -> Self {
let now = Instant::now();
Self {
last: now,
accum: 0.0,
frames: 0,
fps: 0,
start: now,
}
}
/// Advance one frame; returns elapsed seconds since start.
fn tick(&mut self) -> f32 {
let now = Instant::now();
let dt = now.duration_since(self.last).as_secs_f32();
self.last = now;
self.accum += dt;
self.frames += 1;
if self.accum >= 0.5 {
self.fps = (self.frames as f32 / self.accum).round() as i32;
self.accum = 0.0;
self.frames = 0;
}
now.duration_since(self.start).as_secs_f32()
}
}
/// Build and run the application window.
pub fn run() -> Result<()> {
let ctx = pollster::block_on(GpuContext::new_headless())?;
log::info!("adapter: {} ({:?})", ctx.adapter_name(), ctx.backend());
let window = AppWindow::new()?;
window.set_adapter(ctx.adapter_name().into());
window.set_backend(format!("{:?}", ctx.backend()).to_uppercase().into());
let target = Rc::new(RefCell::new(RenderTarget::new(&ctx, 1280, 720)?));
let clock = Rc::new(RefCell::new(FrameClock::new()));
// Desired canvas size, applied once per frame rather than per resize
// event. A window drag emits dozens of events a second, and each one
// would otherwise reallocate the texture.
let pending_size = Rc::new(std::cell::Cell::new((1280u32, 720u32)));
// Resize the render target when the canvas area changes. Slint delivers
// this per-dimension, so both callbacks land on the same handler.
{
let pending = pending_size.clone();
window.on_canvas_resized(move |w, h| {
if w > 0 && h > 0 {
pending.set((w as u32, h as u32));
}
});
}
// 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);
});
}
// Seed from the initial window size; `window-resized` maintains it after.
{
let size = window.window().size();
let scale = window.window().scale_factor().max(0.01);
apply_layout_class(&window, size.width as f32 / scale);
}
// Drive rendering from a timer rather than a redraw hook: v0.1 animates
// continuously to make a stalled frame obvious. Real rendering is
// event-driven (NFR-RES-3 forbids continuous redraw when idle).
let timer = slint::Timer::default();
{
let weak = window.as_weak();
let target = target.clone();
let clock = clock.clone();
let pending_size = pending_size.clone();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(16),
move || {
let Some(window) = weak.upgrade() else { return };
let elapsed = clock.borrow_mut().tick();
// Apply at most one resize per frame, and cap the render
// resolution. FR-DSP-1 renders at what the viewport needs,
// not at whatever size the window happens to be — on a large
// display an uncapped canvas costs far more than it shows.
{
let (w, h) = pending_size.get();
let (w, h) = clamp_render_size(w, h);
let mut t = target.borrow_mut();
if t.size() != (w, h) {
t.resize(w, h);
}
}
let target = target.borrow();
target.render(elapsed);
match to_slint_image(&target) {
Ok(img) => window.set_canvas(img),
Err(e) => log::error!("frame failed: {e}"),
}
let fps = clock.borrow().fps;
window.set_fps(fps);
if std::env::var_os("DR_LOG_FPS").is_some() && fps > 0 {
log::info!("frame: {fps} fps, canvas {:?}", target.size());
}
},
);
}
window.run()?;
Ok(())
}
/// Upper bound on render resolution.
///
/// FR-DSP-1: the display pipeline works at the resolution the viewport needs,
/// not the source resolution. The same reasoning applies to the window — a
/// maximised 4K canvas costs 4× a 1080p one for detail nobody is looking at
/// while dragging. Real zoom-to-1:1 will render the visible crop at full
/// resolution instead of scaling the whole canvas up.
const MAX_RENDER_DIM: u32 = 2048;
fn clamp_render_size(w: u32, h: u32) -> (u32, u32) {
let w = w.max(1);
let h = h.max(1);
let longest = w.max(h);
if longest <= MAX_RENDER_DIM {
return (w, h);
}
let scale = MAX_RENDER_DIM as f32 / longest as f32;
(
((w as f32 * scale).round() as u32).max(1),
((h as f32 * scale).round() as u32).max(1),
)
}
/// Width at which the expanded layout appears (FR-UI-1).
///
/// A threshold in 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;
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());
}
/// Convert the render target into something Slint can display.
///
/// **This is the temporary path.** It reads pixels back to the CPU, which
/// ARCH §6.1 forbids in production. Spike S1 replaces it with texture
/// adoption; until then this keeps the app runnable on both platforms so the
/// rest of the shell can be built.
#[cfg(feature = "readback")]
fn to_slint_image(target: &RenderTarget) -> Result<slint::Image> {
use slint::{Rgba8Pixel, SharedPixelBuffer};
let (w, h) = target.size();
let pixels = pollster::block_on(target.read_pixels())?;
let buffer = SharedPixelBuffer::<Rgba8Pixel>::clone_from_slice(&pixels, w, h);
Ok(slint::Image::from_rgba8(buffer))
}
#[cfg(not(feature = "readback"))]
fn to_slint_image(_target: &RenderTarget) -> Result<slint::Image> {
anyhow::bail!(
"zero-copy texture adoption is not implemented yet (spike S1). \
Build with --features readback for the temporary CPU path."
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_size_is_capped_preserving_aspect() {
// Under the cap, untouched.
assert_eq!(clamp_render_size(1600, 900), (1600, 900));
// Over the cap, scaled down with aspect preserved.
let (w, h) = clamp_render_size(3840, 2160);
assert_eq!(w, MAX_RENDER_DIM);
assert!((h as f32 - 1152.0).abs() < 2.0, "got {h}");
// Degenerate sizes never produce a zero dimension.
assert_eq!(clamp_render_size(0, 0), (1, 1));
let (w, h) = clamp_render_size(4000, 1);
assert_eq!(w, MAX_RENDER_DIM);
assert!(h >= 1);
}
#[test]
fn frame_clock_reports_after_window() {
let mut c = FrameClock::new();
// Before half a second elapses there is no average to report.
assert_eq!(c.fps, 0);
let t = c.tick();
assert!(t >= 0.0);
}
}
+166
View File
@@ -0,0 +1,166 @@
import { Theme } from "theme.slint";
// Status strip — surfaces the GPU backend and adapter, which matters during
// v0.1 because assumption A1 is exactly "does this compositing path work on
// this hardware". Seeing the adapter at a glance makes vendor differences
// obvious during the S1/S2 spikes.
component StatusBar inherits Rectangle {
in property <string> adapter;
in property <string> backend;
in property <string> layout-class;
in property <int> fps;
height: 28px;
background: Theme.surface;
HorizontalLayout {
padding-left: Theme.gap;
padding-right: Theme.gap;
spacing: Theme.gap;
alignment: start;
Text {
text: root.backend;
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
vertical-alignment: center;
}
Text {
text: root.adapter;
color: Theme.ink-dim;
font-size: Theme.text-sm;
vertical-alignment: center;
overflow: elide;
}
Rectangle { horizontal-stretch: 1; }
Text {
text: root.layout-class;
color: Theme.ink-faint;
font-size: Theme.text-sm;
vertical-alignment: center;
}
Text {
text: root.fps + " fps";
color: root.fps >= 55 ? Theme.ink-dim : Theme.accent;
font-size: Theme.text-sm;
vertical-alignment: center;
}
}
Rectangle {
y: parent.height - 1px;
height: 1px;
background: Theme.rule;
}
}
// A placeholder panel standing in for the adjustment controls that FR-DEV-3a
// will generate from operation descriptors.
component SidePanel inherits Rectangle {
background: Theme.surface;
VerticalLayout {
padding: Theme.gap;
spacing: Theme.gap;
alignment: start;
Text {
text: "DEVELOP";
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
Text {
text: "Controls are generated from operation\ndescriptors in v0.2 (FR-DEV-3a).";
color: Theme.ink-faint;
font-size: Theme.text-sm;
wrap: word-wrap;
}
}
Rectangle {
width: 1px;
background: Theme.rule;
}
}
export component AppWindow inherits Window {
title: "DarkRoom";
background: Theme.ground;
preferred-width: 1100px;
preferred-height: 720px;
min-width: 360px;
min-height: 320px;
// Set from Rust each frame: the compute output, delivered as a texture.
in property <image> canvas;
in property <string> adapter: "detecting…";
in property <string> backend: "—";
in property <int> fps: 0;
// FR-UI-1: layout class follows window width, not device type. A narrow
// desktop window gets the compact layout, exactly as a tablet would.
//
// Set from Rust rather than derived from `root.width` here: a property
// read inside the layout and also feeding it creates a binding loop,
// which Slint warns about and which can panic at runtime.
in property <bool> expanded: true;
in property <string> layout-class: "expanded";
callback canvas-resized(int, int);
callback window-resized(length);
// One-way: report width outward, never read layout back into it.
changed width => { root.window-resized(self.width); }
VerticalLayout {
StatusBar {
adapter: root.adapter;
backend: root.backend;
layout-class: root.layout-class;
fps: root.fps;
}
HorizontalLayout {
// The canvas: compute output composited directly. No CPU
// round-trip anywhere in this path (ARCH §6.1).
canvas-area := Rectangle {
horizontal-stretch: 1;
background: Theme.ground;
clip: true;
Image {
width: 100%;
height: 100%;
source: root.canvas;
image-fit: contain;
}
// Report size changes so the render target can be resized to
// match. Width and height are tracked separately because
// Slint has no single "geometry changed" hook.
property <int> px-w: Math.round(self.width / 1px);
property <int> px-h: Math.round(self.height / 1px);
changed px-w => { root.canvas-resized(self.px-w, self.px-h); }
changed px-h => { root.canvas-resized(self.px-w, self.px-h); }
}
// Always instantiated, width collapsed to zero in compact mode.
// A conditional `if` here creates a binding loop — the layout
// depends on `expanded`, which derives from the window width,
// which the layout then influences. Slint flags it, and it can
// panic at runtime.
SidePanel {
width: root.expanded ? 260px : 0px;
visible: root.expanded;
}
}
}
}
+29
View File
@@ -0,0 +1,29 @@
// Darkroom safelight palette. Warm neutrals, single red accent.
// Committed to a dark ground — this is a photo editor, and a light UI
// surrounding an image biases how that image is judged.
export global Theme {
out property <color> ground: #14120F;
out property <color> surface: #1D1A16;
out property <color> surface-raised: #262119;
out property <color> rule: #332C24;
out property <color> ink: #F0EAE0;
out property <color> ink-dim: #A79E91;
out property <color> ink-faint: #7C7367;
out property <color> accent: #D9543C;
out property <color> accent-dim: #8F2E1E;
out property <length> gap-sm: 6px;
out property <length> gap: 12px;
out property <length> gap-lg: 20px;
out property <length> text-sm: 11px;
out property <length> text: 13px;
out property <length> text-lg: 17px;
out property <length> text-xl: 24px;
// FR-UI-3: minimum 44pt hit target under touch.
out property <length> touch-target: 44px;
}