Files
DarkRoom/ui/dr-ui/src/launch.rs
T
dtourolle 8ad5c86ff9 Add the library, collections, and trash views; theme from style.yaml
The UI gains the views the catalog work was building toward: a windowed
library grid with ratings and flags, the collection tree with drag-to-add,
and trash with restore. derived_sync pushes thumbnail shards and the catalog
snapshot to the server's derived folder.

Tokens now have one source of truth. build.rs reads style.yaml and generates
theme.slint into OUT_DIR, which answers every existing
`import { Theme } from "theme.slint"` unchanged, because Slint resolves
imports against the importing file's directory first and the include paths
after. Generating into OUT_DIR rather than beside the hand-written Slint is
the point: a generated file sitting in ui/ looks exactly like the files
around it that are meant to be edited, and an edit to it would survive until
the next touch of style.yaml — a bug that hides for weeks. build.rs fails
loudly if a stale ui/theme.slint exists, which would otherwise shadow the
generated one silently and make every palette change vanish with no error.

The palette moves to near-neutral dark with achromatic signalling, so the
accent means "modified" or "active" rather than "heading". Shared components
land in widgets.slint: a token that binds several values into one concept is
a component, not a row in a YAML file.

Adds an optional live-style feature that makes the tokens in-out so they can
be written at startup — a feature rather than the default because it stops
the properties being constant-folded.

serde_norway is the YAML crate: serde_yaml and serde_yml are both deprecated,
and its mappings preserve insertion order, which is what lets the generated
Slint keep the token ordering the author chose.

Assisted-by: LLM
2026-08-09 21:11:38 +02:00

672 lines
22 KiB
Rust

//! 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>>,
},
}
/// What the app opens on.
///
/// Three outcomes, and the middle one is easy to lose: a configured library
/// means the launch screen is skipped, and skipping it must not also skip
/// opening the library — otherwise the app lands on an empty view with no
/// route back to the grid, because the "Open library" button is on the screen
/// that was never shown.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Startup {
/// Files were named on the command line; show those.
ShowLocalFiles,
/// An account and a folder are configured; scan and show the grid.
OpenLibrary,
/// Nothing configured; ask the user to sign in.
ShowLaunchScreen,
}
/// 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)>,
/// Folder picker state, `None` when it is closed.
pub browser: Option<FolderBrowser>,
}
/// The folder picker: where it is, and what is there.
///
/// Descends one level at a time because that is what the backend supports —
/// `Depth: infinity` is frequently disabled server-side and prohibitively
/// expensive where it is not (ARCH §8.4).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct FolderBrowser {
/// Path being listed. Empty is the account root.
pub path: String,
/// Child folder names at `path`, not full paths.
pub entries: Vec<String>,
/// True while a listing is in flight.
pub loading: bool,
}
impl FolderBrowser {
/// Breadcrumb segments, root first.
pub fn breadcrumbs(&self) -> Vec<String> {
let mut out = vec!["/".to_string()];
out.extend(
self.path
.split('/')
.filter(|s| !s.is_empty())
.map(str::to_string),
);
out
}
/// The path after descending into `name`.
pub fn child_path(&self, name: &str) -> String {
if self.path.is_empty() {
name.to_string()
} else {
format!("{}/{}", self.path, name)
}
}
/// The parent path, or `None` at the root.
pub fn parent_path(&self) -> Option<String> {
if self.path.is_empty() {
None
} else {
Some(match self.path.rsplit_once('/') {
Some((head, _)) => head.to_string(),
None => String::new(),
})
}
}
/// Truncate to the breadcrumb at `index` (0 is the root).
pub fn path_at_crumb(&self, index: usize) -> String {
if index == 0 {
return String::new();
}
self.path
.split('/')
.filter(|s| !s.is_empty())
.take(index)
.collect::<Vec<_>>()
.join("/")
}
}
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(),
browser: None,
}
}
}
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()
}
/// What the app should do on startup.
///
/// Pure so it can be tested without a secret store or a display server —
/// and it needs testing, because the interesting case is the one with no
/// visible symptom until you are staring at an empty window.
pub fn startup_action(&self, have_local_paths: bool) -> Startup {
if have_local_paths {
// Files named on the command line win: the user asked for those
// specifically, not for their library.
Startup::ShowLocalFiles
} else if self.can_open_library() {
Startup::OpenLibrary
} else {
Startup::ShowLaunchScreen
}
}
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,
};
}
// --- folder picker --------------------------------------------------
/// Open the picker at the account root.
pub fn open_browser(&mut self) {
self.error = None;
self.browser = Some(FolderBrowser {
path: String::new(),
entries: Vec::new(),
loading: true,
});
}
pub fn close_browser(&mut self) {
self.browser = None;
}
/// Begin listing `path`.
pub fn browse_to(&mut self, path: impl Into<String>) {
let path = path.into();
match &mut self.browser {
Some(b) => {
b.path = path;
b.entries.clear();
b.loading = true;
}
None => {
self.browser = Some(FolderBrowser {
path,
entries: Vec::new(),
loading: true,
})
}
}
}
/// Record a completed listing.
pub fn browser_loaded(&mut self, entries: Vec<String>) {
if let Some(b) = &mut self.browser {
b.entries = entries;
b.loading = false;
}
}
/// Adopt the picker's current path as the library root.
///
/// Returns the session to persist, or `None` when signed out.
pub fn choose_current_folder(&mut self) -> Option<Session> {
let path = self.browser.as_ref()?.path.clone();
let mut session = self.session()?.clone();
session.root = path;
self.browser = None;
self.state = LaunchState::SignedIn {
session: session.clone(),
};
Some(session)
}
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_configured_library_opens_rather_than_showing_nothing() {
// The regression this guards: skipping the launch screen because a
// library is configured used to mean the library was never opened,
// since `on_open_library` only fires from a button nobody saw.
let mut m = LaunchModel::default();
m.signed_in(session_with_root("PhotosRaw"));
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
}
#[test]
fn no_configuration_shows_the_launch_screen() {
let m = LaunchModel::default();
assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen);
}
#[test]
fn a_signed_in_account_without_a_folder_still_needs_the_screen() {
// Opening without a chosen folder would scan the whole account.
let mut m = LaunchModel::default();
m.signed_in(session_with_root(""));
assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen);
}
#[test]
fn command_line_files_win_over_a_configured_library() {
// The user asked for those files specifically.
let mut m = LaunchModel::default();
m.signed_in(session_with_root("PhotosRaw"));
assert_eq!(m.startup_action(true), Startup::ShowLocalFiles);
}
#[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 browsing_descends_and_ascends() {
let mut m = LaunchModel::default();
m.signed_in(session_with_root(""));
m.open_browser();
let b = m.browser.as_ref().unwrap();
assert_eq!(b.path, "", "opens at the account root");
assert!(b.loading);
m.browser_loaded(vec!["Photos".into(), "Archive".into()]);
assert!(!m.browser.as_ref().unwrap().loading);
let child = m.browser.as_ref().unwrap().child_path("Photos");
assert_eq!(child, "Photos");
m.browse_to(child);
m.browser_loaded(vec!["2026".into()]);
let deeper = m.browser.as_ref().unwrap().child_path("2026");
assert_eq!(deeper, "Photos/2026");
m.browse_to(deeper);
assert_eq!(
m.browser.as_ref().unwrap().parent_path(),
Some("Photos".into())
);
}
#[test]
fn the_root_has_no_parent() {
let b = FolderBrowser::default();
assert_eq!(b.parent_path(), None, "no way up from the account root");
}
#[test]
fn ascending_from_a_top_level_folder_reaches_the_root() {
let b = FolderBrowser {
path: "Photos".into(),
..Default::default()
};
assert_eq!(b.parent_path(), Some(String::new()));
}
#[test]
fn confirming_sets_the_library_root() {
let mut m = LaunchModel::default();
m.signed_in(session_with_root(""));
m.open_browser();
m.browse_to("Photos/2026");
let session = m.choose_current_folder().expect("a session");
assert_eq!(session.root, "Photos/2026");
assert_eq!(m.library_root(), "Photos/2026");
assert!(m.browser.is_none(), "picker closes on confirm");
assert!(m.can_open_library());
}
#[test]
fn confirming_at_the_root_selects_the_whole_account() {
// Legitimate: a user may keep everything at the top level.
let mut m = LaunchModel::default();
m.signed_in(session_with_root(""));
m.open_browser();
let session = m.choose_current_folder().expect("a session");
assert_eq!(session.root, "");
}
#[test]
fn cancelling_leaves_the_root_unchanged() {
let mut m = LaunchModel::default();
m.signed_in(session_with_root("Original"));
m.open_browser();
m.browse_to("Somewhere/Else");
m.close_browser();
assert!(m.browser.is_none());
assert_eq!(m.library_root(), "Original", "cancel must not select");
}
#[test]
fn confirming_while_signed_out_does_nothing() {
let mut m = LaunchModel::default();
m.open_browser();
assert!(m.choose_current_folder().is_none());
}
#[test]
fn breadcrumbs_start_at_the_root() {
let b = FolderBrowser {
path: "Photos/2026/Trip".into(),
..Default::default()
};
assert_eq!(b.breadcrumbs(), vec!["/", "Photos", "2026", "Trip"]);
assert_eq!(b.path_at_crumb(0), "");
assert_eq!(b.path_at_crumb(2), "Photos/2026");
}
#[test]
fn account_label_is_empty_when_signed_out() {
assert_eq!(LaunchModel::default().account_label(), "");
}
}