The UI gains the views the catalog work was building toward: a windowed
library grid with ratings and flags, the collection tree with drag-to-add,
and trash with restore. derived_sync pushes thumbnail shards and the catalog
snapshot to the server's derived folder.
Tokens now have one source of truth. build.rs reads style.yaml and generates
theme.slint into OUT_DIR, which answers every existing
`import { Theme } from "theme.slint"` unchanged, because Slint resolves
imports against the importing file's directory first and the include paths
after. Generating into OUT_DIR rather than beside the hand-written Slint is
the point: a generated file sitting in ui/ looks exactly like the files
around it that are meant to be edited, and an edit to it would survive until
the next touch of style.yaml — a bug that hides for weeks. build.rs fails
loudly if a stale ui/theme.slint exists, which would otherwise shadow the
generated one silently and make every palette change vanish with no error.
The palette moves to near-neutral dark with achromatic signalling, so the
accent means "modified" or "active" rather than "heading". Shared components
land in widgets.slint: a token that binds several values into one concept is
a component, not a row in a YAML file.
Adds an optional live-style feature that makes the tokens in-out so they can
be written at startup — a feature rather than the default because it stops
the properties being constant-folded.
serde_norway is the YAML crate: serde_yaml and serde_yml are both deprecated,
and its mappings preserve insertion order, which is what lets the generated
Slint keep the token ordering the author chose.
Assisted-by: LLM
370 lines
13 KiB
Plaintext
370 lines
13 KiB
Plaintext
import { Theme } from "theme.slint";
|
|
import { Button, PanelHeading, Label, Value, Caption, Panel, Field, Disclosure } from "widgets.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).
|
|
|
|
// One tick-box in the format selection.
|
|
component FormatCheck inherits Rectangle {
|
|
in property <string> label;
|
|
in-out property <bool> checked;
|
|
callback toggled(bool);
|
|
|
|
// FR-UI-3: 44pt minimum hit target under touch. The drawn row is
|
|
// shorter, so the touch area extends beyond the visible bounds.
|
|
height: 32px;
|
|
|
|
touch := TouchArea {
|
|
height: max(parent.height, Theme.touch-target);
|
|
y: (parent.height - self.height) / 2;
|
|
clicked => {
|
|
root.checked = !root.checked;
|
|
root.toggled(root.checked);
|
|
}
|
|
}
|
|
|
|
HorizontalLayout {
|
|
spacing: Theme.gap;
|
|
alignment: start;
|
|
|
|
Rectangle {
|
|
width: 18px;
|
|
height: 18px;
|
|
y: (parent.height - self.height) / 2;
|
|
border-radius: 3px;
|
|
border-width: 1px;
|
|
// A ticked box is an engaged control, so it fills with `active`
|
|
// — the same token the slider fill and the focus border take.
|
|
border-color: root.checked ? Theme.active : Theme.rule;
|
|
background: root.checked ? Theme.active : transparent;
|
|
|
|
Text {
|
|
text: "✓";
|
|
// Dark on the fill: `active` is near-white, and the white
|
|
// tick this carried against a saturated accent is invisible
|
|
// against it.
|
|
color: Theme.ground;
|
|
font-size: 12px;
|
|
visible: root.checked;
|
|
horizontal-alignment: center;
|
|
vertical-alignment: center;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
}
|
|
|
|
Label {
|
|
text: root.label;
|
|
emphasised: touch.has-hover;
|
|
body: true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 { text: root.is-parent ? "↑" : "▸"; }
|
|
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);
|
|
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;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
// --- 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: FormatCheck {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|