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
+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);
}
}