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".
510 lines
22 KiB
Plaintext
510 lines
22 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).
|
||
//
|
||
// **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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|