Files
DarkRoom/ui/dr-ui/ui/launch.slint
T
dtourolle 0fa9003e54 Let the top level be chosen as the library root
Confirming "/" in the folder picker set an empty root, which the launch
model read as no root at all: "Open library" stayed disabled after the
question had plainly been answered, and a folder library — whose folder
is the whole library — could never be opened without first descending
into a subfolder of it. The empty string was carrying two meanings.

Record the choice as its own fact on the account (`root_chosen`, defaulted
so existing configuration loads unchanged), treat a folder endpoint as
chosen by definition, and let the launch screen say so: a folder is shown
as a LIBRARY rather than an ACCOUNT, the second question becomes an
optional "scan only a subfolder", and the library header names the folder
instead of calling it "· whole account".
2026-09-20 00:21:13 +02:00

510 lines
22 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).
//
// **The first screen converted to `@tr()`** (NFR-A11Y-1), and this one first
// because it is the one a user cannot get past: an interface they cannot read
// is unusable here in a way it is not in a preferences page they could ignore.
// The mechanism, the extraction command and the reason a build with no
// translation behaves identically are in `build.rs`.
//
// Four kinds of string are deliberately *not* wrapped, and the distinction is
// worth stating because "wrap every literal" is the obvious rule and the wrong
// one:
//
// - **"DarkRoom".** A product name, the same in every language. Translating
// it invites a translator to answer the question, and there is no answer.
// - **Example values** — `https://cloud.example.com`, `/home/you/Pictures`,
// the app-password mask. These are shapes rather than sentences; a
// translated hostname would teach the wrong format.
// - **`".."`**, the row that leads out of a folder. A filesystem convention,
// not a word.
// - **`"/"`**, the path separator.
//
// The headings are wrapped and carry their own capitals — `PanelHeading` draws
// what it is given — so a translator supplies "SERVEUR" rather than "Serveur".
// That is a real cost of styling in the string, and it is recorded here rather
// than discovered by the first person to translate the screen.
// 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.
//
// A `Rectangle` with a `TouchArea` over it is a button as far as the user is
// concerned and a decorated box as far as the platform is, so the name reaches
// a screen reader — the `Value` below is a `Text` — while the fact that it can
// be entered does not. Saying so is what makes the picker navigable rather
// than merely readable (NFR-A11Y-2).
component FolderRow inherits Rectangle {
in property <string> label;
in property <bool> is-parent: false;
callback clicked();
accessible-role: button;
accessible-label: root.label;
accessible-action-default => { root.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: "";
/// The endpoint is the whole library — a folder — so there is no root
/// to choose inside it, only, optionally, a subfolder to narrow to. A
/// server account's endpoint is the entire tree and a root is required.
in property <bool> endpoint-is-library: false;
in property <string> server-url: "";
// Two endpoints, shown together. Someone deciding between a server and a
// folder should not have to clear one field to try the other.
in property <string> folder-path: "";
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);
// A folder library: no browser, no credential, no waiting state — the
// whole sign-in is checking the directory is there.
callback use-folder(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(); }
}
// Scrollable, and it has to be. The signed-out screen offers three routes
// — browser sign-in, an app password, a folder — and the column is taller
// than a laptop window in a side-by-side split, let alone a phone. A
// centred `VerticalLayout` that overflows clips at *both* ends, so the
// masthead and the last route disappear together and there is no
// indication either existed.
//
// `viewport-height` follows the content rather than being fixed: the
// screen's height changes by hundreds of pixels as it moves between
// signed-out, awaiting approval, and signed-in, and a constant would
// either strand a scrollbar on the short states or clip the tall one.
Flickable {
viewport-height: max(self.height, column.preferred-height);
column := VerticalLayout {
width: 100%;
alignment: start;
padding: Theme.gap-lg;
// Centres the column when it fits and lets it start at the top
// when it does not — the two cases the fixed `alignment: center`
// could not both serve.
padding-top: max(
Theme.gap-lg,
(root.height - card.preferred-height - 2 * Theme.gap-lg) / 2
);
Rectangle {
max-width: 460px;
horizontal-stretch: 0;
card := 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
? @tr("Connected")
: @tr("Connect a Nextcloud account, or open a folder");
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: @tr("SERVER"); }
server-input := Field {
// The `PanelHeading` above each of these entries names
// it on screen and names nothing at all as far as the
// accessibility tree is concerned — Slint associates a
// heading with the control below it only if something
// says so. `Field.label` is that something, so the word
// is written twice on purpose.
label: @tr("Server address");
text: root.server-url;
placeholder: "https://cloud.example.com";
accepted(url) => { root.sign-in(url); }
}
if !root.can-remember: Caption {
text: @tr("No system keyring found — you will need to sign in each time.");
warn: true;
wrap: word-wrap;
}
FormButton {
text: root.busy ? @tr("Connecting…") : @tr("Sign in");
primary: true;
enabled: !root.busy && server-input.text != "";
clicked => { root.sign-in(server-input.text); }
}
Caption {
text: @tr("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: @tr("or"); }
Rectangle {
height: 1px;
background: Theme.rule;
horizontal-stretch: 1;
}
}
PanelHeading { text: @tr("USERNAME"); }
user-input := Field {
label: @tr("Username");
text: "";
placeholder: @tr("your Nextcloud username");
}
PanelHeading { text: @tr("APP PASSWORD"); }
pass-input := Field {
label: @tr("App password");
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 ? @tr("Connecting…") : @tr("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: @tr("Create one in Nextcloud under Settings › Security › Devices & sessions. It is device-scoped and can be revoked on its own.");
wrap: word-wrap;
}
// --- or: a folder on this machine ---
//
// A local disk, a mounted share, or the folder the
// Nextcloud desktop client already syncs. No account and
// no credential, so this is the route that works on a
// machine with no keyring at all.
HorizontalLayout {
spacing: Theme.gap;
alignment: center;
Rectangle {
height: 1px;
background: Theme.rule;
horizontal-stretch: 1;
}
Caption { text: @tr("or"); }
Rectangle {
height: 1px;
background: Theme.rule;
horizontal-stretch: 1;
}
}
PanelHeading { text: @tr("FOLDER"); }
folder-input := Field {
label: @tr("Library folder");
text: root.folder-path;
placeholder: "/home/you/Pictures";
accepted(path) => { root.use-folder(path); }
}
FormButton {
text: @tr("Open folder");
enabled: !root.busy && folder-input.text != "";
clicked => { root.use-folder(folder-input.text); }
}
Caption {
text: @tr("Any folder this machine can read: a local disk, a network mount, or one your Nextcloud client already syncs. Nothing is uploaded and no password is needed.");
wrap: word-wrap;
}
}
// --- login pending: the browser step ---
if root.login-url != "": VerticalLayout {
spacing: Theme.gap;
PanelHeading { text: @tr("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: @tr("Copy link");
clicked => { root.copy-login-url(); }
}
Caption { text: @tr("Waiting for approval…"); }
}
// --- folder picker ---
if root.signed-in && root.browsing: VerticalLayout {
spacing: Theme.gap;
PanelHeading {
text: root.endpoint-is-library ? @tr("SCAN ONLY A SUBFOLDER") : @tr("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: @tr("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: @tr("No subfolders here");
horizontal-alignment: center;
width: 100%;
height: 100%;
}
}
HorizontalLayout {
spacing: Theme.gap;
FormButton {
text: @tr("Cancel");
horizontal-stretch: 1;
clicked => { root.browse-cancel(); }
}
FormButton {
text: @tr("Use this folder");
primary: true;
horizontal-stretch: 1;
clicked => { root.browse-confirm(); }
}
}
}
// --- signed in ---
if root.signed-in && !root.browsing: VerticalLayout {
spacing: Theme.gap;
// A folder *is* the library, and saying "account" of a
// directory, then asking for a "library folder" a second
// time, reads as though the path just typed was not
// taken. A server account genuinely has both: who it is,
// and which subtree of it to catalogue.
PanelHeading { text: root.endpoint-is-library ? @tr("LIBRARY") : @tr("ACCOUNT"); }
Value { text: root.account; }
Rectangle { height: Theme.gap-sm; }
PanelHeading {
text: root.endpoint-is-library ? @tr("SCAN") : @tr("LIBRARY FOLDER");
}
HorizontalLayout {
spacing: Theme.gap;
Value {
text: root.library-root == "" ? @tr("(not chosen)")
: root.library-root == "/" && root.endpoint-is-library ? @tr("The whole folder")
: root.library-root;
placeholder: root.library-root == "";
horizontal-stretch: 1;
overflow: elide;
}
FormButton {
text: root.endpoint-is-library ? @tr("Subfolder…") : @tr("Choose…");
width: 110px;
clicked => { root.choose-folder(); }
}
}
Rectangle { height: Theme.gap-sm; }
PanelHeading { text: @tr("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 ? @tr("Scanning…") : @tr("Open library");
primary: true;
enabled: !root.busy && root.library-root != "";
clicked => { root.open-library(); }
}
FormButton {
text: @tr("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;
}
}
}
}
}
}