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
+479 -46
View File
@@ -14,8 +14,15 @@
//! pipeline what parameters it has and builds a control per answer; no code
//! in `ui/` names an operation or knows a shader exists (FR-DEV-3a).
mod collections_ui;
mod derived_sync;
mod develop;
mod labels;
mod library;
mod library_ui;
mod trash;
#[cfg(live_style)]
mod live_style;
use std::cell::RefCell;
use std::path::{Path, PathBuf};
@@ -48,10 +55,10 @@ const EXPANDED_MIN_WIDTH: f32 = 820.0;
/// Everything loaded for the currently displayed image.
struct Loaded {
/// A develop session where the file could be decoded to sensor data.
/// `None` for a JPEG or a body rawler cannot decode, in which case
/// `fallback` carries an embedded preview and the adjust panel is
/// disabled rather than shown doing nothing.
/// A develop session. `None` only where the file could not be opened for
/// editing at all — a body rawler cannot decode, or a corrupt JPEG — in
/// which case `fallback` carries an embedded preview and the adjust panel
/// is disabled rather than shown doing nothing.
session: Option<DevelopSession>,
fallback: Option<slint::Image>,
meta: Metadata,
@@ -77,33 +84,73 @@ fn load(ctx: Option<&dr_gpu::GpuContext>, path: &Path) -> Result<Loaded, String>
}
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
let meta = dr_decode::metadata(&bytes).unwrap_or_default();
load_bytes(ctx, &bytes)
}
/// Open already-fetched bytes.
///
/// Split from [`load`] because a library image has no local file: it arrives
/// as a WebDAV response body, and writing it to disk purely to read it back
/// would be a round-trip for nothing.
fn load_bytes(ctx: Option<&dr_gpu::GpuContext>, bytes: &[u8]) -> Result<Loaded, String> {
let meta = dr_decode::metadata(bytes).unwrap_or_default();
// Route by what the bytes actually are, not by extension (M-9).
//
// A JPEG has no sensor data and never will, so trying the RAW decoder
// first would be a guaranteed failure whose log line reads like a fault.
// It goes straight to the RGB path instead, which is what makes develop
// mode work on the JPEGs the library already indexes.
let is_jpeg = dr_decode::probe(bytes) == Some(dr_types::Format::Jpeg);
// Try sensor data first. A failure here is expected for JPEGs and for
// bodies rawler does not know, and must not stop the image displaying
// (FR-RAW-4).
if let Some(ctx) = ctx {
match dr_decode::decode(&bytes) {
Ok(raw) => match DevelopSession::open(ctx, &raw) {
Ok(session) => {
let (width, height) = session.source_size();
return Ok(Loaded {
session: Some(session),
fallback: None,
meta,
width,
height,
});
}
Err(e) => log::info!("develop unavailable, showing preview: {e}"),
},
Err(e) => log::info!("no sensor data ({e}); showing preview"),
let opened = if is_jpeg {
dr_decode::decode_jpeg(bytes)
.map_err(|e| e.to_string())
.and_then(|mut p| {
// Fit the device before uploading. A film scan runs to
// 13728×8928, well past the 8192 a typical GPU can hold,
// and refusing it would drop the image back to a
// read-only preview — the very thing this path exists to
// avoid. 8192 is still four times a 4K long edge.
let limit = dr_gpu::DemosaicedImage::max_dimension(ctx);
if p.width.max(p.height) > limit {
log::info!(
"{}×{} exceeds the {limit} texture limit; fitting to it",
p.width,
p.height
);
p.downscale_to(limit);
}
DevelopSession::open_rgb(ctx, &p.rgba, p.width, p.height)
})
} else {
// A failure here is expected for bodies rawler does not know, and
// must not stop the image displaying (FR-RAW-4).
dr_decode::decode(bytes)
.map_err(|e| e.to_string())
.and_then(|raw| DevelopSession::open(ctx, &raw))
};
match opened {
Ok(session) => {
let (width, height) = session.source_size();
return Ok(Loaded {
session: Some(session),
fallback: None,
meta,
width,
height,
});
}
Err(e) => log::info!("develop unavailable, showing preview: {e}"),
}
}
// Fall back to the embedded preview, which is all a JPEG has anyway.
// Fall back to the embedded preview: no GPU, or a file neither decoder
// could open for editing. Read-only, and the adjust panel is disabled.
let mut preview =
dr_decode::extract_preview(&bytes, PreviewSize::Screen).map_err(|e| e.to_string())?;
dr_decode::extract_preview(bytes, PreviewSize::Screen).map_err(|e| e.to_string())?;
preview.downscale_to(MAX_DISPLAY_DIM);
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(
@@ -162,6 +209,21 @@ fn is_supported(p: &Path) -> bool {
.is_some()
}
/// Return the view to its opening state for a newly loaded image.
///
/// Zoom and crop mode are properties of *looking at one photograph*, so
/// carrying them to the next one would leave the second image cropped to a
/// rect chosen for the first.
fn reset_view_state(window: &AppWindow) {
window.set_crop_mode(false);
window.set_zoom(1.0);
window.set_zoomed(false);
window.set_crop_x(0.0);
window.set_crop_y(0.0);
window.set_crop_w(1.0);
window.set_crop_h(1.0);
}
/// Push current parameter values back to the interface.
///
/// The controls are not self-updating: the core clamps values, so what the
@@ -186,10 +248,27 @@ fn sync_rows(
};
if current.len() == rows.row_count() {
for (i, row) in current.into_iter().enumerate() {
for (i, mut row) in current.into_iter().enumerate() {
let existing = rows.row_data(i);
// A curve row carries a *nested* model of point coordinates, and
// `rows()` builds a fresh one each call. Swapping it in would
// destroy the point elements — including the `TouchArea` holding
// the current drag — so the existing model is kept and its values
// written through instead.
//
// It also makes the equality test below meaningful: `ModelRc`
// compares by identity, so a brand-new points model would make
// every curve row look changed on every event.
if let Some(previous) = existing.as_ref() {
if update_points_in_place(&previous.points, &row.points) {
row.points = previous.points.clone();
}
}
// Only touch rows that actually changed, so unrelated controls
// are not needlessly invalidated.
if rows.row_data(i).as_ref() != Some(&row) {
if existing.as_ref() != Some(&row) {
rows.set_row_data(i, row);
}
}
@@ -204,29 +283,167 @@ fn sync_rows(
window.set_curve_samples(slint::ModelRc::new(slint::VecModel::from(samples)));
}
/// Copy `fresh`'s values into `existing`, keeping the model identity.
///
/// Returns `false` where the two differ in length, in which case the caller
/// must take the new model wholesale — the control set itself has changed and
/// there is no drag worth preserving.
fn update_points_in_place(
existing: &slint::ModelRc<f32>,
fresh: &slint::ModelRc<f32>,
) -> bool {
use slint::Model as _;
if existing.row_count() != fresh.row_count() {
return false;
}
for i in 0..fresh.row_count() {
let (Some(new), Some(old)) = (fresh.row_data(i), existing.row_data(i)) else {
continue;
};
// Guarded so an unchanged coordinate does not invalidate its element
// — the same reasoning as the row-level check above.
if new != old {
existing.set_row_data(i, new);
}
}
true
}
/// TRACES: M-13 | M-14
/// Build and run the viewer.
pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let entries = Rc::new(collect(&paths));
log::info!("{} image(s) to browse", entries.len());
// Mutable because the browsing list has two sources: the command line at
// startup, and whatever the library grid is showing when a cell is
// clicked. Opening from the grid replaces this so next/previous walk the
// library the user is actually looking at rather than the arguments they
// launched with.
let entries = Rc::new(RefCell::new(collect(&paths)));
log::info!("{} image(s) to browse", entries.borrow().len());
let window = AppWindow::new()?;
// Set once `show` exists; see where the library grid is wired below.
#[allow(clippy::type_complexity)]
let open_from_library: Rc<RefCell<Option<Rc<dyn Fn(String)>>>> =
Rc::new(RefCell::new(None));
// Before anything binds to a token: the compiled palette is already in
// place, so this only overwrites what style.yaml currently says.
#[cfg(live_style)]
live_style::apply(&window);
// The library grid: scan the remote tree into the catalog, then show what
// was found. Clicking a cell opens it in develop.
//
// Declared out here rather than inside the launch block below because the
// develop side reads `paths` to rebuild its browsing list when an image is
// opened from the grid.
let library = library_ui::LibraryController::new();
// Launch screen: shown when there is nothing to display — no local paths
// and no configured library. A user who has already signed in and chosen
// a folder goes straight to their images (FR-NC-1).
{
let controller = launch_ui::LaunchController::new();
let show = controller.should_show(!paths.is_empty());
window.set_show_launch(show);
launch_ui::wire(&window, controller, |session| {
// Opening a remote library needs the scan-and-cache path, which
// lands with the catalog. Reporting that plainly beats a button
// that silently does nothing.
log::info!("open library requested for {}", session.describe());
let startup = controller
.model
.borrow()
.startup_action(!paths.is_empty());
window.set_show_launch(startup == launch::Startup::ShowLaunchScreen);
let library = library.clone();
let collections = collections_ui::CollectionsController::new();
// The click handler needs `show`, which is built further down because
// it captures the develop session and the GPU context. This cell is
// the knot between them: wired empty here, filled once `show` exists.
// A click before then is a no-op rather than a panic — the grid cannot
// be reached until the window is running, by which point it is set.
let open_from_library = open_from_library.clone();
library_ui::wire(
&window,
library.clone(),
collections.clone(),
move |path| {
let Some(f) = open_from_library.borrow().clone() else {
log::warn!("open requested before the viewer was ready: {path}");
return;
};
f(path);
},
);
// The collections sidebar shares the library's catalog handle rather
// than opening its own: one SQLite connection, so an edit here is
// visible to the grid's next read without a reopen.
//
// The reload closure is the seam between the two controllers. The
// sidebar decides *what* is scoped; the library owns the window, the
// offset and the thumbnail workers, so it is what actually reloads —
// and it must be told the scope before it reads, which is why both
// happen here in one place rather than each controller reaching for the
// other.
{
let weak = window.as_weak();
let lib = library.clone();
let coll = collections.clone();
let lib_ids = library.clone();
let lib_session = library.clone();
collections_ui::wire(
&window,
collections.clone(),
library.catalog(),
move || {
let Some(w) = weak.upgrade() else { return };
// Order matters: `set_scope` clears the trash flag, because
// picking a collection is how you leave the trash. Setting
// the flag second is what lets selecting the trash itself
// survive the call.
lib.set_scope(coll.scope());
lib.set_viewing_trash(coll.viewing_trash());
library_ui::reload(&w, &lib);
},
move || lib_ids.visible_ids(),
// The trash's MOVE and DELETE go to the same account the scan
// and thumbnail workers use.
move || lib_session.session(),
);
}
let weak = window.as_weak();
let store_ctl = controller.clone();
let lib = library.clone();
let coll = collections.clone();
launch_ui::wire(&window, controller.clone(), move |session| {
let Some(w) = weak.upgrade() else { return };
log::info!("opening library for {}", session.describe());
library_ui::open(&w, lib.clone(), coll.clone(), &store_ctl.store, session);
});
if show {
log::info!("no library configured — showing the launch screen");
match startup {
launch::Startup::ShowLaunchScreen => {
log::info!("no library configured — showing the launch screen");
}
launch::Startup::ShowLocalFiles => {
log::info!("{} file(s) named on the command line", paths.len());
}
// Skipping the launch screen must not mean skipping the library:
// the "Open library" button lives on the screen we just bypassed,
// so nothing else would ever start the scan.
launch::Startup::OpenLibrary => {
let session = controller.model.borrow().session().cloned();
if let Some(session) = session {
log::info!("resuming library for {}", session.describe());
library_ui::open(
&window,
library.clone(),
collections.clone(),
&controller.store,
session,
);
}
}
}
}
@@ -246,7 +463,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
}
};
window.set_total(entries.len() as i32);
window.set_total(entries.borrow().len() as i32);
let index = Rc::new(RefCell::new(0usize));
// The current develop session, if the file yielded sensor data.
let session: Rc<RefCell<Option<DevelopSession>>> = Rc::new(RefCell::new(None));
@@ -269,10 +486,25 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return };
let (w, h) = *viewport.borrow();
match s.render(w, h) {
// Crop mode shows the whole frame, or the area being cropped away
// would not be on screen for the handles to drag across. The
// overlay draws the rect on top of it.
let rendered = if window.get_crop_mode() {
s.render_uncropped(w, h).map(|(image, _, _)| image)
} else {
s.render(w, h)
};
match rendered {
Ok(image) => {
window.set_canvas(image);
window.set_load_error("".into());
// The readout and the "Fit" button follow the session
// rather than the gesture, so a clamped zoom shows the
// value that was actually applied.
window.set_zoom(s.zoom());
window.set_zoomed(s.is_zoomed());
}
Err(e) => {
log::warn!("render failed: {e}");
@@ -291,13 +523,20 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let rows = rows.clone();
Rc::new(move |window: &AppWindow| {
let i = *index.borrow();
let Some(path) = entries.get(i) else { return };
// Cloned rather than held: `load` below is slow, and keeping the
// list borrowed across it would panic the moment anything else
// touched `entries`.
let Some(path) = entries.borrow().get(i).cloned() else {
return;
};
let path = path.as_path();
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
reset_view_state(window);
window.set_filename(name.clone().into());
window.set_index(i as i32);
@@ -349,6 +588,108 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
})
};
// Now `show` exists, close the knot left open at the library wiring.
//
// The grid's paths are *remote*: there is no local file to open, so the
// click starts a download and the image appears when it lands. That is a
// whole RAW file over WebDAV, so the wait is real and has to be visible —
// the status line says so rather than leaving a blank frame.
{
let weak = window.as_weak();
let library = library.clone();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
let gpu = gpu.clone();
*open_from_library.borrow_mut() = Some(Rc::new(move |path: String| {
let Some(w) = weak.upgrade() else { return };
let name = path.rsplit('/').next().unwrap_or(&path).to_string();
reset_view_state(&w);
w.set_filename(name.clone().into());
w.set_load_error("".into());
w.set_camera("".into());
w.set_exposure("".into());
w.set_dimensions("".into());
// The grid is one image at a time, so next/previous have nothing
// to walk. Shown as 1 of 1 rather than left reading 0.
w.set_index(0);
w.set_total(1);
let Some((creds, user_id)) = library.credentials() else {
w.set_load_error("no library session".into());
return;
};
log::info!("fetching {path} for develop");
w.set_load_error("Downloading…".into());
let rx = library::spawn_full_fetch(creds, user_id, path.clone());
// Polled on the UI thread rather than joined: a join would freeze
// the window for the length of the download.
let weak = w.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
let gpu = gpu.clone();
let timer = Rc::new(slint::Timer::default());
let held = timer.clone();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(50),
move || {
let Ok(got) = rx.try_recv() else { return };
// Landed — this timer has done its job.
held.stop();
let Some(w) = weak.upgrade() else { return };
let bytes = match got {
Ok(b) => b,
Err(e) => {
log::warn!("{name}: {e}");
w.set_load_error(e.into());
return;
}
};
log::info!("{name}: {} bytes fetched", bytes.len());
match load_bytes(gpu.as_ref(), &bytes) {
Ok(l) => {
w.set_load_error("".into());
w.set_camera(describe_camera(&l.meta).into());
w.set_exposure(describe_exposure(&l.meta).into());
w.set_dimensions(format!("{} × {}", l.width, l.height).into());
match l.session {
Some(s) => {
w.set_adjust_enabled(true);
*session.borrow_mut() = Some(s);
sync_rows(&w, &rows, &session);
redraw(&w);
}
None => {
*session.borrow_mut() = None;
rows.set_vec(Vec::<ParamRow>::new());
w.set_adjust_enabled(false);
if let Some(image) = l.fallback {
w.set_canvas(image);
}
}
}
log::info!("{name}: {}×{}", l.width, l.height);
}
Err(e) => {
log::warn!("{name}: {e}");
*session.borrow_mut() = None;
w.set_adjust_enabled(false);
w.set_load_error(e.into());
}
}
},
);
}));
}
// ---- Adjustment callbacks ------------------------------------------
//
// Generic by construction: they carry indices into the capability list,
@@ -413,6 +754,96 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
});
}
// ---- zoom, pan and crop ---------------------------------------------
//
// Zoom and pan are viewing state and touch no parameter, so unlike the
// handlers above they do not `sync_rows`.
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_zoom_at(move |factor, at_x, at_y| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.zoom_about(factor, at_x, at_y);
}
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_pan_by(move |dx, dy| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.pan_by(dx, dy);
}
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_zoom_reset(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.reset_zoom();
}
redraw(&w);
});
}
{
// Entering crop mode drops the zoom: the handles are placed against
// the whole frame, and a zoomed view would put most of that frame off
// screen where it cannot be dragged.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_crop_mode_toggled(move |on| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
if on {
s.reset_zoom();
let c = s.crop();
w.set_crop_x(c.x);
w.set_crop_y(c.y);
w.set_crop_w(c.width);
w.set_crop_h(c.height);
}
}
w.set_crop_mode(on);
redraw(&w);
});
}
{
// The rect arrives raw from the drag; the session normalises it, and
// the properties are written back from what it actually stored. That
// round trip is what makes an over-drag slide along the edge rather
// than letting the overlay and the pipeline disagree.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_crop_changed(move |x, y, width, height| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_crop(dr_pipeline::CropRect {
x,
y,
width,
height,
});
let c = s.crop();
w.set_crop_x(c.x);
w.set_crop_y(c.y);
w.set_crop_w(c.width);
w.set_crop_h(c.height);
}
redraw(&w);
});
}
{
let weak = window.as_weak();
let index = index.clone();
@@ -420,14 +851,15 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let show = show.clone();
window.on_next_image(move || {
let Some(w) = weak.upgrade() else { return };
if entries.is_empty() {
let len = entries.borrow().len();
if len == 0 {
return;
}
// Read, then write — `*x.borrow_mut() = *x.borrow() + 1` holds
// both borrows at once and panics.
let next = {
let cur = *index.borrow();
(cur + 1) % entries.len()
(cur + 1) % len
};
*index.borrow_mut() = next;
show(&w);
@@ -440,13 +872,14 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let show = show.clone();
window.on_prev_image(move || {
let Some(w) = weak.upgrade() else { return };
if entries.is_empty() {
let len = entries.borrow().len();
if len == 0 {
return;
}
let prev = {
let cur = *index.borrow();
if cur == 0 {
entries.len() - 1
len - 1
} else {
cur - 1
}
@@ -489,7 +922,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
apply_layout_class(&window, size.width as f32 / scale);
}
if !entries.is_empty() {
if !entries.borrow().is_empty() {
show(&window);
}