Files
DarkRoom/ui/dr-ui/ui/launch.slint
T
dtourolleandClaude Opus 5 0a331c717e Give the controls a vocabulary, and let a node ask for one
widgets.slint set the rule — screens consume components, and a bare `Theme.*`
at a call site means a component is missing — and it set it for chrome only.
The controls never got the same treatment, so they were written wherever they
were first needed and copied from there.

**The slider was private to the develop panel.** `SliderTrack`, with the
fifty-line preamble explaining how it wrests a drag away from a Flickable,
lived inside adjust.slint and no other screen could reach it. It shows: export
quality is a 1-to-100 value, and the settings page offered a free-text box for
it, with the range written in a hint and enforced nowhere. `to-float()` answers
0 for anything it cannot parse, so a typo saved a quality of 0 and the page
displayed the 0 back as though it had been asked for.

The tick-box was written twice, in launch.slint and settings.slint, from the
same 18px box and the same handler; the second carried a comment deferring the
lift until a third caller appeared. The label-and-hint header was written three
times inside settings.slint alone.

controls.slint is the input layer beside widgets.slint's chrome layer, and the
constraint that makes it reusable is that **nothing in it knows about
`ParamRow`** — that struct is the develop panel's flattening of the capability
model, and a control that imported it could only ever be used by the develop
panel. The primitives take plain numbers; the ParamRow-shaped wrappers stay in
the panel that owns the model. 658 lines came out of the three screens.

`SliderRow` is the slider-plus-number-box ARCH §4.3 names as the pointer
presentation of a bounded scalar, and quality is its first adopter. It commits
on gesture end rather than on every movement, because the settings page saves
to disk on change and a two-second drag is a couple of hundred writes where a
text field committed once. The develop panel keeps the live stream — that is
what its pipeline is for — so `SliderTrack` now reports both.

**The other half is the descriptor.** FR-DEV-3a and ARCH §4.3a already specify
more than was built: an ordered preference list of widgets rather than one, the
demands a widget makes, and kinds beyond scalar and bool.

- `Presentation.widgets` is now a list, walked by `choose`, falling back to
  plain sliders. Falling off the end is not an error, and there is a test
  asserting an operation asking only for an unimplemented widget still yields
  one control per parameter.
- `WidgetDemand` carries what a widget inherently needs — two-dimensional
  dragging, precise pointing — and no pixels, breakpoints or platform names.
- `WidgetKind` grows to the specified set. There is deliberately no `Colour`
  *kind*: a colour is three numbers, and a value type that is not an `f32`
  would reach through the graph, the uniform block and the sidecar format to
  buy what `ColourWheel` over three scalars already describes. Every widget
  here is a hint over ordinary scalars, which is what keeps the fallback
  honest.
- `ParamKind::Enum` is the one new shape, and it fits because a variant index
  is exact in binary32. `kind: enum` with a `variants:` list works in
  `ops/*.yaml`, so a node declaring one gets a segmented control with no UI
  file edited — which is the promise ops/mod.rs already makes.

The panel's dispatch was duplicated: a lone parameter and a grouped one each
wrote out their own list of kinds, so `enum` would have had to be added twice
and a kind added to one would appear or vanish depending on how many parameters
its operation happened to declare. `ParamControl` is now the only such chain.

`rows_from` is free-standing rather than a method, which is what lets the
FR-DEV-3c acceptance test requirements.md asks for actually be written: an
operation the frontend has never heard of, appearing in a generated panel, with
no GPU in sight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:21:35 +02:00

382 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Theme } from "theme.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, Field, Disclosure } from "widgets.slint";
import { Check } from "controls.slint";
// Launch screen: connect an account, or resume a saved one.
//
// Deliberately separate from AppWindow. It is the first thing a user sees
// with no library configured, and the place they return to in order to sign
// out or switch account (FR-NC-1, FR-NC-4).
// This screen's buttons are form actions in a single stacked column, not
// chrome beside a photograph — they are given the full `touch-target` height
// rather than the denser `control-height` the toolbars use. `Button` draws
// its own hit area at least this tall either way; here the ink matches it.
component FormButton inherits Button {
height: Theme.touch-target;
}
// One folder in the picker. The whole row is the target, not just the text.
component FolderRow inherits Rectangle {
in property <string> label;
in property <bool> is-parent: false;
callback clicked();
height: Theme.touch-target;
background: touch.has-hover ? Theme.surface-raised : transparent;
border-radius: 3px;
touch := TouchArea { clicked => { root.clicked(); } }
HorizontalLayout {
padding-left: Theme.gap;
padding-right: Theme.gap;
spacing: Theme.gap;
Disclosure { icon: root.is-parent ? "arrow-up" : "chevron-right"; }
Value { text: root.label; overflow: elide; }
}
}
export component LaunchScreen inherits Rectangle {
// --- state in ---
in property <bool> signed-in: false;
in property <string> account: "";
in property <string> library-root: "";
in property <string> server-url: "";
in property <bool> busy: false;
in property <string> status: "";
in property <string> error: "";
/// The URL to approve in a browser, shown while a login is pending.
in property <string> login-url: "";
/// False where no secrets daemon exists — the user must be told sign-in
/// will not persist rather than discovering it next launch.
in property <bool> can-remember: true;
in-out property <[string]> format-labels;
in-out property <[bool]> format-checked;
// --- events out ---
callback sign-in(string);
/// server, username, app password
callback sign-in-direct(string, string, string);
callback sign-out();
callback choose-folder();
callback open-library();
callback format-toggled(int, bool);
callback copy-login-url();
// --- folder picker ---
in property <bool> browsing: false;
in property <string> browse-path: "";
in property <bool> browse-loading: false;
in-out property <[string]> browse-entries;
callback browse-into(string);
callback browse-up();
callback browse-confirm();
callback browse-cancel();
background: Theme.ground;
// Somewhere for a key to start from — see the identical holder in
// `settings.slint` for why a screen with only text fields needs one.
FocusScope {
width: 0px;
height: 0px;
init => { self.focus(); }
}
VerticalLayout {
alignment: center;
padding: Theme.gap-lg;
Rectangle {
max-width: 460px;
horizontal-stretch: 0;
VerticalLayout {
spacing: Theme.gap-lg;
// --- masthead ---
VerticalLayout {
spacing: Theme.gap-sm;
// The one piece of type in the app that is a title rather
// than a label, so it is written here rather than given a
// component with a single call site.
Text {
text: "DarkRoom";
color: Theme.ink;
font-size: Theme.text-xl;
font-weight: 800;
letter-spacing: -0.5px;
}
Label {
text: root.signed-in
? "Connected"
: "Connect a Nextcloud account to begin";
body: true;
}
}
Rectangle { height: 1px; background: Theme.rule; }
// --- signed out: server entry ---
if !root.signed-in && root.login-url == "": VerticalLayout {
spacing: Theme.gap;
PanelHeading { text: "SERVER"; }
server-input := Field {
text: root.server-url;
placeholder: "https://cloud.example.com";
accepted(url) => { root.sign-in(url); }
}
if !root.can-remember: Caption {
text: "No system keyring found — you will need to sign in each time.";
warn: true;
wrap: word-wrap;
}
FormButton {
text: root.busy ? "Connecting…" : "Sign in";
primary: true;
enabled: !root.busy && server-input.text != "";
clicked => { root.sign-in(server-input.text); }
}
Caption {
text: "Sign-in happens in your browser. DarkRoom never sees your password.";
wrap: word-wrap;
}
// --- or: an app password entered directly ---
//
// The browser flow above is preferred because no credential
// ever reaches us. This exists for the cases it cannot
// serve: no browser able to complete the handshake, or a
// user who would rather paste a token they can revoke.
HorizontalLayout {
spacing: Theme.gap;
alignment: center;
Rectangle {
height: 1px;
background: Theme.rule;
horizontal-stretch: 1;
}
Caption { text: "or"; }
Rectangle {
height: 1px;
background: Theme.rule;
horizontal-stretch: 1;
}
}
PanelHeading { text: "USERNAME"; }
user-input := Field {
text: "";
placeholder: "your Nextcloud username";
}
PanelHeading { text: "APP PASSWORD"; }
pass-input := Field {
text: "";
placeholder: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx";
secret: true;
accepted(pw) => {
root.sign-in-direct(server-input.text, user-input.text, pw);
}
}
FormButton {
text: root.busy ? "Connecting…" : "Connect directly";
enabled: !root.busy
&& server-input.text != ""
&& user-input.text != ""
&& pass-input.text != "";
clicked => {
root.sign-in-direct(
server-input.text,
user-input.text,
pass-input.text,
);
}
}
Caption {
text: "Create one in Nextcloud under Settings › Security › Devices & sessions. It is device-scoped and can be revoked on its own.";
wrap: word-wrap;
}
}
// --- login pending: the browser step ---
if root.login-url != "": VerticalLayout {
spacing: Theme.gap;
PanelHeading { text: "APPROVE IN YOUR BROWSER"; }
Panel {
Label {
text: root.login-url;
// char-wrap, not word-wrap: a URL has no spaces to
// break at, and word-wrap would run it off the box.
wrap: char-wrap;
}
}
FormButton {
text: "Copy link";
clicked => { root.copy-login-url(); }
}
Caption { text: "Waiting for approval…"; }
}
// --- folder picker ---
if root.signed-in && root.browsing: VerticalLayout {
spacing: Theme.gap;
PanelHeading { text: "CHOOSE LIBRARY FOLDER"; }
// Current location, so it is always clear what
// "Use this folder" would select.
Value {
text: root.browse-path == "" ? "/" : "/" + root.browse-path;
overflow: elide;
}
// Not a `Panel`: this box overlays three mutually
// exclusive states — loading, the list, "nothing here" —
// each filling it, and Panel stacks its children in a
// column. Same surface and rule, drawn directly.
Rectangle {
height: 220px;
background: Theme.surface;
border-radius: Theme.radius;
border-width: 1px;
border-color: Theme.rule;
if root.browse-loading: Caption {
text: "Loading…";
horizontal-alignment: center;
width: 100%;
height: 100%;
}
if !root.browse-loading: Flickable {
width: 100%;
height: 100%;
viewport-height: content.preferred-height;
content := VerticalLayout {
padding: 4px;
spacing: 2px;
alignment: start;
if root.browse-path != "": FolderRow {
label: "..";
is-parent: true;
clicked => { root.browse-up(); }
}
for name in root.browse-entries: FolderRow {
label: name;
clicked => { root.browse-into(name); }
}
}
}
if !root.browse-loading && root.browse-entries.length == 0
&& root.browse-path != "": Caption {
text: "No subfolders here";
horizontal-alignment: center;
width: 100%;
height: 100%;
}
}
HorizontalLayout {
spacing: Theme.gap;
FormButton {
text: "Cancel";
horizontal-stretch: 1;
clicked => { root.browse-cancel(); }
}
FormButton {
text: "Use this folder";
primary: true;
horizontal-stretch: 1;
clicked => { root.browse-confirm(); }
}
}
}
// --- signed in ---
if root.signed-in && !root.browsing: VerticalLayout {
spacing: Theme.gap;
PanelHeading { text: "ACCOUNT"; }
Value { text: root.account; }
Rectangle { height: Theme.gap-sm; }
PanelHeading { text: "LIBRARY FOLDER"; }
HorizontalLayout {
spacing: Theme.gap;
Value {
text: root.library-root == "" ? "(not chosen)" : root.library-root;
placeholder: root.library-root == "";
horizontal-stretch: 1;
overflow: elide;
}
FormButton {
text: "Choose…";
width: 110px;
clicked => { root.choose-folder(); }
}
}
Rectangle { height: Theme.gap-sm; }
PanelHeading { text: "SCAN FOR"; }
for label[i] in root.format-labels: Check {
label: label;
checked: root.format-checked[i];
toggled(on) => { root.format-toggled(i, on); }
}
Rectangle { height: Theme.gap; }
FormButton {
text: root.busy ? "Scanning…" : "Open library";
primary: true;
enabled: !root.busy && root.library-root != "";
clicked => { root.open-library(); }
}
FormButton {
text: "Sign out";
clicked => { root.sign-out(); }
}
}
// --- feedback ---
if root.status != "": Label {
text: root.status;
wrap: word-wrap;
}
// A failed sign-in or scan is a caution, not an active state:
// something the user has to act on rather than something the
// app is doing. It was the same accent as the headings above
// it, which said nothing had gone wrong.
if root.error != "": Caption {
text: root.error;
warn: true;
wrap: word-wrap;
}
}
}
}
}