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:
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
slint_build::compile("ui/app.slint").expect("compiling app.slint");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user