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
This commit is contained in:
2026-08-09 21:11:38 +02:00
parent 7900184383
commit 8ad5c86ff9
20 changed files with 12192 additions and 489 deletions
+66
View File
@@ -29,6 +29,23 @@ pub enum LaunchState {
},
}
/// 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)]
@@ -197,6 +214,23 @@ impl LaunchModel {
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))
}
@@ -393,6 +427,38 @@ mod tests {
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();