Files
DarkRoom/ui/dr-ui/ui/app.slint
T
dtourolle 9717e59909 Add RAW decode and a working image viewer
dr-decode exposes four entry points rather than one decode, because
callers differ sharply in what they need (ARCH §3.2): culling wants a
preview, the grid wants metadata, only develop and export touch sensor
data. Fusing them forces a full decode where a header read suffices,
which is why Lightroom stalls ~2s per image while culling.

Smoke-tested against 1,852 real Canon CR2 files (EOS 6D, ~27MB each):

  metadata      0.2ms   from a 256KB header read, no full decode
  preview     ~250ms   5472x3648, downscaled to 2048 for display
  jpeg          3.0ms

Two findings worth recording:

rawler 0.7.2's CR2 decoder implements only full_image; thumbnail_image
and preview_image are unimplemented trait defaults returning None. So
every rung of the preview ladder resolves to a full-resolution decode at
~250ms — 5x over NFR-P13's 50ms budget. CR2 does carry smaller IFDs, so
the fix is our own IFD walk or an upstream contribution. The ladder is
written now so that fixing it is a decoder change, not a change to every
caller. Recorded in milestone-v0.1 risks.

Preview.downscale_to bounds memory: a 5472x3648 RGBA preview is 79.8MB,
which exhausts a phone's budget after a handful of images. Box-filtered
so downscaled thumbnails do not alias.

Also fixed a RefCell double-borrow that panicked on first navigation —
`*x.borrow_mut() = *x.borrow() + 1` holds both borrows at once. Verified
with 10,000 programmatic navigations.

58 tests passing. Traceability 20.3% (29/143).
2026-08-09 08:28:26 +02:00

255 lines
8.1 KiB
Plaintext

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;
in property <string> filename;
in property <string> position;
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.filename;
color: Theme.ink;
font-size: Theme.text-sm;
font-weight: 600;
vertical-alignment: center;
overflow: elide;
}
Text {
text: root.position;
color: Theme.ink-faint;
font-size: Theme.text-sm;
vertical-alignment: center;
}
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 {
in property <string> camera;
in property <string> exposure;
in property <string> dimensions;
background: Theme.surface;
VerticalLayout {
padding: Theme.gap;
spacing: Theme.gap;
alignment: start;
Text {
text: "IMAGE";
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
Text {
text: root.camera == "" ? "—" : root.camera;
color: Theme.ink;
font-size: Theme.text;
wrap: word-wrap;
}
Text {
text: root.exposure;
color: Theme.ink-dim;
font-size: Theme.text-sm;
}
Text {
text: root.dimensions;
color: Theme.ink-faint;
font-size: Theme.text-sm;
}
Rectangle { height: Theme.gap; }
Text {
text: "← → to navigate";
color: Theme.ink-faint;
font-size: Theme.text-sm;
}
}
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;
// Current image, for the status strip and empty state.
in property <string> filename: "";
in property <string> camera: "";
in property <string> exposure: "";
in property <string> dimensions: "";
in property <int> index: 0;
in property <int> total: 0;
in property <string> load-error: "";
callback next-image();
callback prev-image();
// 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;
filename: root.filename;
position: root.total > 0 ? root.index + 1 + " / " + root.total : "";
}
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;
visible: root.total > 0 && root.load-error == "";
}
// Empty and error states say what happened rather than
// showing a blank canvas.
if root.total == 0 || root.load-error != "": VerticalLayout {
alignment: center;
spacing: Theme.gap;
Text {
text: root.load-error != "" ? "Could not load image" : "No images";
color: Theme.ink-dim;
font-size: Theme.text-lg;
horizontal-alignment: center;
}
Text {
text: root.load-error != "" ? root.load-error
: "Pass a folder or file on the command line.";
color: Theme.ink-faint;
font-size: Theme.text-sm;
horizontal-alignment: center;
wrap: word-wrap;
}
}
// Arrow keys and space step through the folder.
FocusScope {
key-pressed(event) => {
if (event.text == Key.RightArrow || event.text == " ") {
root.next-image();
return accept;
}
if (event.text == Key.LeftArrow) {
root.prev-image();
return accept;
}
return reject;
}
}
// 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;
camera: root.camera;
exposure: root.exposure;
dimensions: root.dimensions;
}
}
}
}