A heading above a text field names it on screen and names nothing to the platform: Slint associates the two only if something says so, and nothing did. So the four entries a user signs in through — server, username, app password, folder — reached AT-SPI as unnamed boxes with a separate piece of static text floating above each. The word is now written twice, deliberately, and the second copy is the one attached to the control being typed into. FolderRow is the more consequential half. It is a Rectangle with a TouchArea over it, which is a button to the user and a decorated box to the platform — its label got through, because the `Value` inside is a Text, while the fact that the row could be entered at all did not. The folder picker was therefore readable and not navigable, which for the screen that chooses where the whole library lives is the difference between using the application and not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
495 lines
20 KiB
Plaintext
495 lines
20 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: "";
|
||
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: @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;
|
||
|
||
PanelHeading { text: @tr("ACCOUNT"); }
|
||
Value { text: root.account; }
|
||
|
||
Rectangle { height: Theme.gap-sm; }
|
||
|
||
PanelHeading { text: @tr("LIBRARY FOLDER"); }
|
||
HorizontalLayout {
|
||
spacing: Theme.gap;
|
||
Value {
|
||
text: root.library-root == "" ? @tr("(not chosen)") : root.library-root;
|
||
placeholder: root.library-root == "";
|
||
horizontal-stretch: 1;
|
||
overflow: elide;
|
||
}
|
||
FormButton {
|
||
text: @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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|