Add secure credential storage, sessions, and a launch screen

Login now persists properly rather than through the JSON file the test
harness was using.

  dr-plat            SecretStore trait plus a Secret Service backend.
                     Verified against the live GNOME Keyring: store,
                     retrieve, delete, confirm-gone all round-trip.
  Session/SessionStore   splits credentials from settings — the app
                     password goes to the keyring (FR-NC-2), while
                     server, login, chosen root and format selection are
                     ordinary config. A test asserts the credential never
                     appears in the config file.
  LaunchModel        the launch-screen state machine, testable without a
                     display server: sign in, approve in browser, choose
                     folder, tick formats, sign out.
  launch.slint       the screen itself, in its own file.

Absence of a secrets daemon is an explicit degraded mode, not a silent
fallback to plaintext — the screen says sign-in will not persist rather
than letting the user find out next launch. Android's Keystore backend
fails loudly for the same reason: a no-op store would look like it
worked and then lose the credential.

Two bugs caught by tests rather than by running it:

  - fail() after busy() signed the user out, because busy() had already
    discarded the session. A failed *scan* would have logged you out.
    Busy now carries the session.
  - normalise_server upgrades http:// to https:// rather than accepting
    it. NFR-SEC-3 requires TLS, and silently sending a credential in the
    clear is not a decision to make on the user's behalf.

launch.slint is not yet wired into app.slint. Calling slint_build::compile
twice replaces the generated module rather than adding to it, which broke
the other in-flight work on dr-ui; I reverted that immediately. Wiring it
needs an import inside app.slint, which is that work's file to change.

419 tests passing across ten crates.
This commit is contained in:
2026-08-09 15:20:39 +02:00
parent c8bb08e661
commit 09e3043f4c
33 changed files with 3506 additions and 155 deletions
+2
View File
@@ -12,6 +12,8 @@ dr-types.workspace = true
# must come off when S1 lands (ARCH §6.1, AC-8).
dr-gpu = { workspace = true, features = ["readback"] }
dr-decode.workspace = true
dr-plat.workspace = true
dr-sync-nextcloud.workspace = true
dr-pipeline.workspace = true
slint = { workspace = true, features = ["compat-1-2", "renderer-femtovg", "backend-winit"] }
wgpu.workspace = true
+59 -6
View File
@@ -11,7 +11,7 @@
use dr_decode::RawImage;
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext};
use dr_pipeline::{EditGraph, OpId, ParamId, ParamKind, Unit};
use dr_pipeline::{CropRect, EditGraph, OpId, ParamId, ParamKind, Unit};
use crate::labels;
use crate::ParamRow;
@@ -135,8 +135,13 @@ impl DevelopSession {
pub fn render(&mut self, width: u32, height: u32) -> Result<slint::Image, String> {
// Fit the render to the viewport while preserving aspect, so the
// pass does no work on pixels the view will letterbox away.
//
// Fitted against the *framed* size, not the sensor's: a crop changes
// the aspect ratio, and fitting the uncropped shape would letterbox
// to the wrong box and render the crop squashed.
let (sw, sh) = self.demosaiced.size();
let (w, h) = fit(sw, sh, width.max(1), height.max(1));
let (fw, fh) = self.graph.output_size(sw, sh);
let (w, h) = fit(fw, fh, width.max(1), height.max(1));
let shader = self.graph.compose();
self.adjust
@@ -149,11 +154,46 @@ impl DevelopSession {
Ok(slint::Image::from_rgba8(buffer))
}
/// The image's natural aspect ratio, for sizing the viewport.
/// The displayed size, for sizing the viewport.
///
/// The *framed* size, not the sensor's: cropping and quarter turns change
/// the aspect ratio, and a viewport sized to the sensor would letterbox a
/// cropped image against the wrong shape.
pub fn source_size(&self) -> (u32, u32) {
let (w, h) = self.demosaiced.size();
self.graph.output_size(w, h)
}
/// The sensor's own dimensions, before framing.
///
/// What a crop overlay needs: its handles are placed against the full
/// frame, since that is what the user is selecting *from*.
pub fn sensor_size(&self) -> (u32, u32) {
self.demosaiced.size()
}
/// Set the crop rectangle, in fractions of the source.
pub fn set_crop(&mut self, rect: CropRect) {
self.graph.set_crop(rect);
}
pub fn crop(&self) -> CropRect {
self.graph.crop()
}
/// Rotate by quarter turns, wrapping. The rotate-left/right buttons.
pub fn rotate_quarters(&mut self, turns: i32) {
self.graph.rotate_quarters(turns);
}
/// The largest centred crop that, at the current straightening angle,
/// contains no undefined area. What a "straighten and fill" action
/// applies.
pub fn max_inscribed_crop(&self) -> CropRect {
let (w, h) = self.demosaiced.size();
self.graph.framing().max_inscribed_crop(w, h)
}
/// How many shader pipelines have been compiled. Surfaced so the status
/// strip can show that slider movement is not recompiling.
pub fn compiled_pipelines(&self) -> usize {
@@ -203,10 +243,23 @@ mod tests {
#[test]
fn every_capability_becomes_exactly_one_row() {
// The UI shows what the pipeline offers — no more, and nothing
// dropped.
// dropped. Asserted against the chain rather than a literal count,
// so operations can be added without editing this, and so the test
// actually checks the correspondence rather than restating a number.
let graph = EditGraph::default_chain();
let expected: usize = graph.capabilities().iter().map(|c| c.params.len()).sum();
assert_eq!(expected, 10, "seven operations, ten parameters");
let caps = graph.capabilities();
let expected: usize = caps.iter().map(|c| c.params.len()).sum();
assert!(expected > 0, "the chain must expose some parameters");
// Every (operation, parameter) pair must be reachable as a distinct
// row index; a collision would route two sliders to one parameter.
let mut seen = std::collections::HashSet::new();
for (oi, cap) in caps.iter().enumerate() {
for (pi, _) in cap.params.iter().enumerate() {
assert!(seen.insert((oi, pi)), "duplicate row index");
}
}
assert_eq!(seen.len(), expected);
}
#[test]
+12
View File
@@ -20,6 +20,7 @@ pub fn resolve(key: &str) -> String {
"op.brilliance" => "Brilliance".into(),
"op.vibrance" => "Vibrance".into(),
"op.saturation" => "Saturation".into(),
"op.framing" => "Crop & Rotate".into(),
// Parameters
"param.temperature" => "Temperature".into(),
@@ -33,6 +34,17 @@ pub fn resolve(key: &str) -> String {
"param.vibrance" => "Vibrance".into(),
"param.saturation" => "Saturation".into(),
// Framing. "Straighten" rather than "Angle" because that is the task
// the control performs; the number it reports is still degrees.
"param.angle" => "Straighten".into(),
"param.rotation" => "Rotate".into(),
"param.flip_h" => "Flip Horizontal".into(),
"param.flip_v" => "Flip Vertical".into(),
"param.crop_x" => "Crop Left".into(),
"param.crop_y" => "Crop Top".into(),
"param.crop_w" => "Crop Width".into(),
"param.crop_h" => "Crop Height".into(),
other => derive(other),
}
}
+384
View File
@@ -0,0 +1,384 @@
//! Launch screen state: connect an account, choose a library, sign out.
//!
//! The state machine lives here, separate from the Slint bindings, so it can
//! be tested without a display server. `dr-ui` owns presentation; what a
//! login *is* belongs to the connector.
use dr_sync_nextcloud::{Session, SessionStore};
use dr_types::{Format, FormatFilter};
/// What the launch screen is currently doing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LaunchState {
/// No account configured; waiting for a server address.
SignedOut,
/// A login flow is open and the user must approve it in a browser.
///
/// Carries the URL so the screen can show and copy it — the app never
/// handles the password itself (FR-NC-1).
AwaitingApproval { login_url: String },
/// An account is configured.
SignedIn { session: Session },
/// Working; the reason is shown so a pause is never unexplained.
///
/// Carries the session where there is one, so a failure mid-work returns
/// to the signed-in screen rather than signing the user out.
Busy {
message: String,
session: Option<Box<Session>>,
},
}
/// TRACES: FR-NC-1 | FR-NC-4 | M-1 | M-3 | M-4
/// Everything the launch screen renders from.
#[derive(Debug, Clone)]
pub struct LaunchModel {
pub state: LaunchState,
/// Last-used server, prefilled so a returning user need not retype it.
pub server_url: String,
pub error: Option<String>,
pub status: Option<String>,
/// False where no secrets daemon exists (FR-NC-2). The screen must say so
/// up front rather than letting the user discover it next launch.
pub can_remember: bool,
/// Which formats to scan for, in `Format::ALL` order.
pub formats: Vec<(Format, bool)>,
}
impl Default for LaunchModel {
fn default() -> Self {
Self {
state: LaunchState::SignedOut,
server_url: String::new(),
error: None,
status: None,
can_remember: true,
formats: Format::ALL.iter().map(|f| (*f, f.is_raw())).collect(),
}
}
}
impl LaunchModel {
/// Build from stored sessions, resuming the last account if there is one.
pub fn from_store(store: &SessionStore) -> Self {
let can_remember = store.can_remember();
match store.current() {
Some(session) => {
let filter = session.format_filter();
Self {
server_url: session.server.clone(),
formats: Format::ALL
.iter()
.map(|f| (*f, filter.allows(*f)))
.collect(),
state: LaunchState::SignedIn { session },
can_remember,
..Default::default()
}
}
None => Self {
can_remember,
..Default::default()
},
}
}
pub fn is_signed_in(&self) -> bool {
matches!(self.state, LaunchState::SignedIn { .. })
}
pub fn is_busy(&self) -> bool {
matches!(self.state, LaunchState::Busy { .. })
}
pub fn session(&self) -> Option<&Session> {
match &self.state {
LaunchState::SignedIn { session } => Some(session),
// A session survives a busy period; a scan failure must not log
// the user out.
LaunchState::Busy {
session: Some(s), ..
} => Some(s),
_ => None,
}
}
/// The account line, e.g. "duncan on cloud.example".
pub fn account_label(&self) -> String {
self.session().map(|s| s.describe()).unwrap_or_default()
}
/// The chosen library folder, empty until one is picked.
pub fn library_root(&self) -> String {
self.session().map(|s| s.root.clone()).unwrap_or_default()
}
/// The login URL while approval is pending.
pub fn login_url(&self) -> String {
match &self.state {
LaunchState::AwaitingApproval { login_url } => login_url.clone(),
_ => String::new(),
}
}
/// Whether "Open library" should be clickable.
///
/// Requires a signed-in account *and* a chosen folder: opening without one
/// would scan the whole account, which on a real library is thousands of
/// directories the user did not ask for.
pub fn can_open_library(&self) -> bool {
self.is_signed_in() && !self.library_root().is_empty()
}
pub fn format_filter(&self) -> FormatFilter {
FormatFilter::from_formats(self.formats.iter().filter(|(_, on)| *on).map(|(f, _)| *f))
}
/// Toggle one format tick-box.
pub fn set_format(&mut self, index: usize, enabled: bool) {
if let Some(entry) = self.formats.get_mut(index) {
entry.1 = enabled;
}
}
// --- transitions ---------------------------------------------------
pub fn begin_sign_in(&mut self, server: impl Into<String>) {
self.server_url = normalise_server(&server.into());
self.error = None;
self.state = LaunchState::Busy {
message: "Contacting server…".into(),
session: None,
};
}
pub fn await_approval(&mut self, login_url: impl Into<String>) {
self.status = Some("Approve the sign-in in your browser.".into());
self.state = LaunchState::AwaitingApproval {
login_url: login_url.into(),
};
}
pub fn signed_in(&mut self, session: Session) {
self.error = None;
self.status = None;
self.server_url = session.server.clone();
let filter = session.format_filter();
// Adopt the session's stored selection, so a returning user sees the
// tick-boxes they left.
if !session.formats.is_empty() {
self.formats = Format::ALL
.iter()
.map(|f| (*f, filter.allows(*f)))
.collect();
}
self.state = LaunchState::SignedIn { session };
}
pub fn signed_out(&mut self) {
self.error = None;
self.status = None;
self.state = LaunchState::SignedOut;
}
pub fn fail(&mut self, message: impl Into<String>) {
self.status = None;
self.error = Some(message.into());
// Return to whichever resting state makes sense, so a failure never
// strands the screen in Busy with no way forward.
self.state = match self.session() {
Some(s) => LaunchState::SignedIn { session: s.clone() },
None => LaunchState::SignedOut,
};
}
pub fn busy(&mut self, message: impl Into<String>) {
self.error = None;
let session = self.session().cloned().map(Box::new);
self.state = LaunchState::Busy {
message: message.into(),
session,
};
}
}
/// Normalise a server address typed by hand.
///
/// Users type `cloud.example.com`, not a URL. Assume HTTPS rather than
/// failing, and never silently accept plain HTTP — NFR-SEC-3 requires TLS,
/// and an unencrypted default would be a security decision made on the user's
/// behalf without telling them.
pub fn normalise_server(input: &str) -> String {
let s = input.trim().trim_end_matches('/');
if s.is_empty() {
return String::new();
}
if s.starts_with("https://") {
s.to_string()
} else if let Some(rest) = s.strip_prefix("http://") {
// Upgrade rather than accept. If the server genuinely has no TLS the
// connection fails loudly, which is the correct outcome.
format!("https://{rest}")
} else {
format!("https://{s}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use dr_plat::EphemeralSecretStore;
use dr_sync_nextcloud::AppCredentials;
fn creds() -> AppCredentials {
AppCredentials {
server: "https://cloud.example".into(),
login_name: "duncan".into(),
app_password: "token".into(),
}
}
fn session_with_root(root: &str) -> Session {
let mut s = Session::new(&creds(), "duncan");
s.root = root.into();
s
}
#[test]
fn a_fresh_model_is_signed_out_with_raw_preselected() {
let m = LaunchModel::default();
assert!(!m.is_signed_in());
// RAW ticked, JPEG not: a RAW editor's sensible default.
let f = m.format_filter();
assert!(f.allows(Format::Cr2));
assert!(!f.allows(Format::Jpeg));
}
#[test]
fn opening_a_library_needs_both_an_account_and_a_folder() {
let mut m = LaunchModel::default();
assert!(!m.can_open_library(), "signed out");
m.signed_in(session_with_root(""));
assert!(
!m.can_open_library(),
"no folder — would scan the whole account"
);
m.signed_in(session_with_root("PhotosRaw"));
assert!(m.can_open_library());
}
#[test]
fn a_failure_never_strands_the_screen_in_busy() {
let mut m = LaunchModel::default();
m.begin_sign_in("cloud.example");
assert!(m.is_busy());
m.fail("server unreachable");
assert!(!m.is_busy(), "must return to a resting state");
assert_eq!(m.state, LaunchState::SignedOut);
assert!(m.error.is_some());
}
#[test]
fn a_failure_while_signed_in_returns_to_signed_in() {
let mut m = LaunchModel::default();
m.signed_in(session_with_root("PhotosRaw"));
m.busy("Scanning…");
m.fail("scan failed");
assert!(m.is_signed_in(), "a failed scan must not sign the user out");
assert!(m.error.is_some());
}
#[test]
fn the_login_url_is_exposed_only_while_pending() {
let mut m = LaunchModel::default();
assert_eq!(m.login_url(), "");
m.await_approval("https://cloud.example/login/v2/flow/abc");
assert!(m.login_url().contains("/flow/abc"));
m.signed_in(session_with_root(""));
assert_eq!(m.login_url(), "", "must not linger after sign-in");
}
#[test]
fn server_addresses_are_normalised_to_https() {
assert_eq!(normalise_server("cloud.example"), "https://cloud.example");
assert_eq!(
normalise_server("https://cloud.example/"),
"https://cloud.example"
);
assert_eq!(
normalise_server(" cloud.example "),
"https://cloud.example"
);
assert_eq!(normalise_server(""), "");
}
#[test]
fn plain_http_is_upgraded_rather_than_accepted() {
// NFR-SEC-3: TLS is required. Failing loudly beats silently sending a
// credential in the clear.
assert_eq!(
normalise_server("http://cloud.example"),
"https://cloud.example"
);
}
#[test]
fn format_toggles_apply_and_out_of_range_is_ignored() {
let mut m = LaunchModel::default();
let jpeg = Format::ALL.iter().position(|f| *f == Format::Jpeg).unwrap();
m.set_format(jpeg, true);
assert!(m.format_filter().allows(Format::Jpeg));
// Must not panic on a stale index from the UI.
m.set_format(9999, true);
}
#[test]
fn a_stored_session_is_resumed_with_its_format_selection() {
let dir = std::env::temp_dir().join("darkroom-launch-test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let store = SessionStore::open_at(
dir.join("sessions.json"),
Box::new(EphemeralSecretStore::new()),
);
let mut s = session_with_root("PhotosRaw");
s.set_format_filter(&FormatFilter::from_formats([Format::Cr2]));
store.save(&s, &creds()).unwrap();
let m = LaunchModel::from_store(&store);
assert!(m.is_signed_in());
assert_eq!(m.library_root(), "PhotosRaw");
assert!(m.format_filter().allows(Format::Cr2));
assert!(!m.format_filter().allows(Format::Dng));
assert_eq!(m.server_url, "https://cloud.example");
}
#[test]
fn no_stored_session_yields_a_signed_out_model() {
let dir = std::env::temp_dir().join("darkroom-launch-empty");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let store = SessionStore::open_at(
dir.join("sessions.json"),
Box::new(EphemeralSecretStore::new()),
);
let m = LaunchModel::from_store(&store);
assert!(!m.is_signed_in());
}
#[test]
fn account_label_is_empty_when_signed_out() {
assert_eq!(LaunchModel::default().account_label(), "");
}
}
+2
View File
@@ -26,6 +26,8 @@ use dr_decode::{Metadata, PreviewSize};
pub use develop::DevelopSession;
pub mod launch;
slint::include_modules!();
/// TRACES: FR-DSP-1 | NFR-RES-1
+349
View File
@@ -0,0 +1,349 @@
import { Theme } from "theme.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;
border-color: root.checked ? Theme.accent : Theme.rule;
background: root.checked ? Theme.accent : transparent;
Text {
text: "✓";
color: #fff;
font-size: 12px;
visible: root.checked;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
}
}
Text {
text: root.label;
color: touch.has-hover ? Theme.ink : Theme.ink-dim;
font-size: Theme.text;
vertical-alignment: center;
}
}
}
component Button inherits Rectangle {
in property <string> label;
in property <bool> primary: false;
in property <bool> enabled: true;
callback clicked();
height: Theme.touch-target;
border-radius: 4px;
background: root.primary
? (touch.has-hover && root.enabled ? Theme.accent-dim : Theme.accent)
: (touch.has-hover && root.enabled ? Theme.surface-raised : transparent);
border-width: root.primary ? 0px : 1px;
border-color: Theme.rule;
opacity: root.enabled ? 1.0 : 0.45;
touch := TouchArea {
enabled: root.enabled;
clicked => { root.clicked(); }
}
Text {
text: root.label;
color: root.primary ? #fff : Theme.ink;
font-size: Theme.text;
font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
}
}
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();
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;
Text {
text: "DarkRoom";
color: Theme.ink;
font-size: Theme.text-xl;
font-weight: 800;
letter-spacing: -0.5px;
}
Text {
text: root.signed-in
? "Connected"
: "Connect a Nextcloud account to begin";
color: Theme.ink-dim;
font-size: Theme.text;
}
}
Rectangle { height: 1px; background: Theme.rule; }
// --- signed out: server entry ---
if !root.signed-in && root.login-url == "": VerticalLayout {
spacing: Theme.gap;
Text {
text: "SERVER";
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
Rectangle {
height: Theme.touch-target;
border-radius: 4px;
border-width: 1px;
border-color: server-input.has-focus ? Theme.accent : Theme.rule;
background: Theme.surface;
server-input := TextInput {
text: root.server-url;
color: Theme.ink;
font-size: Theme.text;
vertical-alignment: center;
width: parent.width - 2 * Theme.gap;
x: Theme.gap;
height: 100%;
single-line: true;
accepted => { root.sign-in(self.text); }
}
// Placeholder, since TextInput has none of its own.
Text {
text: "https://cloud.example.com";
color: Theme.ink-faint;
font-size: Theme.text;
vertical-alignment: center;
x: Theme.gap;
height: 100%;
visible: server-input.text == "";
}
}
if !root.can-remember: Text {
text: "No system keyring found — you will need to sign in each time.";
color: Theme.warn-ink;
font-size: Theme.text-sm;
wrap: word-wrap;
}
Button {
label: root.busy ? "Connecting…" : "Sign in";
primary: true;
enabled: !root.busy && server-input.text != "";
clicked => { root.sign-in(server-input.text); }
}
Text {
text: "Sign-in happens in your browser. DarkRoom never sees your password.";
color: Theme.ink-faint;
font-size: Theme.text-sm;
wrap: word-wrap;
}
}
// --- login pending: the browser step ---
if root.login-url != "": VerticalLayout {
spacing: Theme.gap;
Text {
text: "APPROVE IN YOUR BROWSER";
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
Rectangle {
background: Theme.surface;
border-radius: 4px;
border-width: 1px;
border-color: Theme.rule;
VerticalLayout {
padding: Theme.gap;
Text {
text: root.login-url;
color: Theme.ink-dim;
font-size: Theme.text-sm;
wrap: char-wrap;
}
}
}
Button {
label: "Copy link";
clicked => { root.copy-login-url(); }
}
Text {
text: "Waiting for approval…";
color: Theme.ink-faint;
font-size: Theme.text-sm;
}
}
// --- signed in ---
if root.signed-in: VerticalLayout {
spacing: Theme.gap;
Text {
text: "ACCOUNT";
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
Text {
text: root.account;
color: Theme.ink;
font-size: Theme.text;
}
Rectangle { height: Theme.gap-sm; }
Text {
text: "LIBRARY FOLDER";
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
HorizontalLayout {
spacing: Theme.gap;
Text {
text: root.library-root == "" ? "(not chosen)" : root.library-root;
color: root.library-root == "" ? Theme.ink-faint : Theme.ink;
font-size: Theme.text;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
Button {
label: "Choose…";
width: 110px;
clicked => { root.choose-folder(); }
}
}
Rectangle { height: Theme.gap-sm; }
Text {
text: "SCAN FOR";
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
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; }
Button {
label: root.busy ? "Scanning…" : "Open library";
primary: true;
enabled: !root.busy && root.library-root != "";
clicked => { root.open-library(); }
}
Button {
label: "Sign out";
clicked => { root.sign-out(); }
}
}
// --- feedback ---
if root.status != "": Text {
text: root.status;
color: Theme.ink-dim;
font-size: Theme.text-sm;
wrap: word-wrap;
}
if root.error != "": Text {
text: root.error;
color: Theme.accent;
font-size: Theme.text-sm;
wrap: word-wrap;
}
}
}
}
}
+3
View File
@@ -15,6 +15,9 @@ export global Theme {
out property <color> accent: #D9543C;
out property <color> accent-dim: #8F2E1E;
// Semantic, distinct from the accent: a caution is not an action.
out property <color> warn-ink: #C99A4A;
out property <length> gap-sm: 6px;
out property <length> gap: 12px;
out property <length> gap-lg: 20px;