It previously fetched the folder list and threw it away into a status line — a button that looked like it worked and did not. Now it opens a browsable picker: click a folder to descend, ".." to go back, "Use this folder" to select, "Cancel" to leave the root unchanged. Descends one level per click because that is what the backend supports: Depth: infinity is frequently disabled server-side and prohibitively expensive where it is not (ARCH §8.4). The chosen root persists immediately on confirm, so it survives a crash before the library is opened. Confirming at the account root is allowed — a user may legitimately keep everything at the top level — and cancelling leaves any previous selection untouched, which a test asserts. Verified against nextcloud.tourolle.paris at both depths: 30 folders at the root, 21 year-folders inside PhotosRaw. 19 launch tests, 38 in dr-ui.
358 lines
12 KiB
Plaintext
358 lines
12 KiB
Plaintext
import { Theme } from "theme.slint";
|
|
import { AdjustPanel, ParamRow } from "adjust.slint";
|
|
import { LaunchScreen } from "launch.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;
|
|
}
|
|
}
|
|
|
|
// Capture metadata. Read-only; the adjustment controls live in AdjustPanel,
|
|
// which is generated from pipeline capabilities rather than written here.
|
|
component InfoPanel inherits Rectangle {
|
|
in property <string> camera;
|
|
in property <string> exposure;
|
|
in property <string> dimensions;
|
|
|
|
background: Theme.surface;
|
|
height: self.preferred-height;
|
|
|
|
VerticalLayout {
|
|
padding: Theme.gap;
|
|
spacing: Theme.gap-sm;
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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: "";
|
|
|
|
// --- launch screen (FR-NC-1, FR-NC-4) ---
|
|
//
|
|
// The app opens here when no library is configured, and returns here to
|
|
// sign out or switch account.
|
|
in property <bool> show-launch: false;
|
|
in property <bool> launch-signed-in: false;
|
|
in property <string> launch-account: "";
|
|
in property <string> launch-root: "";
|
|
in property <string> launch-server: "";
|
|
in property <bool> launch-busy: false;
|
|
in property <string> launch-status: "";
|
|
in property <string> launch-error: "";
|
|
in property <string> launch-login-url: "";
|
|
in property <bool> launch-can-remember: true;
|
|
in-out property <[string]> launch-format-labels;
|
|
in-out property <[bool]> launch-format-checked;
|
|
|
|
callback launch-sign-in(string);
|
|
callback launch-sign-out();
|
|
callback launch-choose-folder();
|
|
callback launch-open-library();
|
|
callback launch-format-toggled(int, bool);
|
|
callback launch-copy-url();
|
|
|
|
in property <bool> launch-browsing: false;
|
|
in property <string> launch-browse-path: "";
|
|
in property <bool> launch-browse-loading: false;
|
|
in-out property <[string]> launch-browse-entries;
|
|
|
|
callback launch-browse-into(string);
|
|
callback launch-browse-up();
|
|
callback launch-browse-confirm();
|
|
callback launch-browse-cancel();
|
|
|
|
callback next-image();
|
|
callback prev-image();
|
|
|
|
// Adjustment controls, generated from what the pipeline reports it can
|
|
// do. This window knows the *shape* of a control, never which operations
|
|
// exist (FR-DEV-3a).
|
|
in property <[ParamRow]> adjust-rows;
|
|
in property <bool> adjust-enabled: false;
|
|
// The tone curve's sampled shape, evaluated by the core so the drawn
|
|
// line and the applied one cannot disagree.
|
|
in property <[float]> curve-samples;
|
|
callback param-changed(int, int, float);
|
|
callback param-reset(int, int);
|
|
callback curve-reset(int);
|
|
callback reset-all();
|
|
|
|
// 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); }
|
|
|
|
// The launch screen replaces the whole window rather than overlaying it:
|
|
// there is no library to look at until an account is configured.
|
|
if root.show-launch: LaunchScreen {
|
|
width: 100%;
|
|
height: 100%;
|
|
signed-in: root.launch-signed-in;
|
|
account: root.launch-account;
|
|
library-root: root.launch-root;
|
|
server-url: root.launch-server;
|
|
busy: root.launch-busy;
|
|
status: root.launch-status;
|
|
error: root.launch-error;
|
|
login-url: root.launch-login-url;
|
|
can-remember: root.launch-can-remember;
|
|
format-labels: root.launch-format-labels;
|
|
format-checked: root.launch-format-checked;
|
|
|
|
sign-in(server) => { root.launch-sign-in(server); }
|
|
sign-out() => { root.launch-sign-out(); }
|
|
choose-folder() => { root.launch-choose-folder(); }
|
|
open-library() => { root.launch-open-library(); }
|
|
format-toggled(i, on) => { root.launch-format-toggled(i, on); }
|
|
copy-login-url() => { root.launch-copy-url(); }
|
|
|
|
browsing: root.launch-browsing;
|
|
browse-path: root.launch-browse-path;
|
|
browse-loading: root.launch-browse-loading;
|
|
browse-entries: root.launch-browse-entries;
|
|
|
|
browse-into(name) => { root.launch-browse-into(name); }
|
|
browse-up() => { root.launch-browse-up(); }
|
|
browse-confirm() => { root.launch-browse-confirm(); }
|
|
browse-cancel() => { root.launch-browse-cancel(); }
|
|
}
|
|
|
|
if !root.show-launch: 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.
|
|
Rectangle {
|
|
width: root.expanded ? 280px : 0px;
|
|
visible: root.expanded;
|
|
background: Theme.surface;
|
|
|
|
VerticalLayout {
|
|
InfoPanel {
|
|
camera: root.camera;
|
|
exposure: root.exposure;
|
|
dimensions: root.dimensions;
|
|
}
|
|
|
|
Rectangle {
|
|
height: 1px;
|
|
background: Theme.rule;
|
|
}
|
|
|
|
AdjustPanel {
|
|
vertical-stretch: 1;
|
|
rows: root.adjust-rows;
|
|
enabled: root.adjust-enabled;
|
|
curve-samples: root.curve-samples;
|
|
param-changed(op, param, value) => {
|
|
root.param-changed(op, param, value);
|
|
}
|
|
param-reset(op, param) => {
|
|
root.param-reset(op, param);
|
|
}
|
|
curve-reset(op) => { root.curve-reset(op); }
|
|
reset-all => { root.reset-all(); }
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
width: 1px;
|
|
background: Theme.rule;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|