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>
382 lines
14 KiB
Plaintext
382 lines
14 KiB
Plaintext
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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|