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 label; in property 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 signed-in: false; in property account: ""; in property library-root: ""; in property 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 folder-path: ""; in property busy: false; in property status: ""; in property error: ""; /// The URL to approve in a browser, shown while a login is pending. in property 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 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 browsing: false; in property browse-path: ""; in property 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; } } } } } }