Make the CI checks say what they mean, and format the workspace
Build and test / Desktop (Linux) (push) Successful in 1h23m26s
Build and test / Android (aarch64) (push) Failing after 2s
Build and test / Layer separation (push) Successful in 50s
Traceability / Requirement traces (push) Failing after 1m9s

The Android job's "Verify minimum API level" step has never verified the
minimum API level. It took the first `*.so` anywhere under the target
directory, which is a host proc-macro from debug/deps — an x86-64 object
built by the runner's gcc, whose .comment section cannot mention Android
and so can never contradict the expected value. It now reads the artifact
under the target triple, compares against MIN_API parsed from the
Dockerfile rather than a second copy of the number, and fails on a
mismatch. Both sides are checked non-empty first: two failed parses would
otherwise compare equal and pass, which is the same silent success in a
new costume.

The Android image installs one SDK package per layer and keeps the
output. sdkmanager is a JVM program that aborts when it cannot get memory,
and the single `> /dev/null` step reported that as a bare "exit code 134"
while a retry re-downloaded everything that had already succeeded.

tools/ci-local.sh runs all four jobs — desktop, android, layering,
traceability — against the host toolchain, which is pinned to the same
1.92.0 CI installs. Its matrix check compares regeneration against the
working tree rather than against HEAD: CI starts from a clean checkout, so
git's answer is the right one there and reports every local run stale here.

The rest is rustfmt across the workspace, and the clippy findings that
surfaced once it did: manual_contains in dr-thumbs and collections_ui, a
map iterated as pairs for its keys, an index loop over a slice, and two
runtime assertions on a constant now made at compile time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 12:02:51 +02:00
co-authored by Claude Opus 5
parent 94a2686dcb
commit 03326242a1
31 changed files with 927 additions and 387 deletions
+26 -6
View File
@@ -148,8 +148,24 @@ fn generate(source: &Path, live: bool) -> Result<String, String> {
out.push_str("export global Theme {\n");
let mut names = BTreeSet::new();
emit_group(&mut out, doc, "colors", "color", direction, &mut names, parse_color)?;
emit_group(&mut out, doc, "lengths", "length", direction, &mut names, parse_length)?;
emit_group(
&mut out,
doc,
"colors",
"color",
direction,
&mut names,
parse_color,
)?;
emit_group(
&mut out,
doc,
"lengths",
"length",
direction,
&mut names,
parse_length,
)?;
if names.is_empty() {
return Err("defines no tokens; expected `colors:` and `lengths:` maps".into());
@@ -230,7 +246,11 @@ fn emit_group(
}
let value = token_value(spec, name, key, parse)?;
writeln!(out, " {direction} property <{slint_type}> {name}: {value};").unwrap();
writeln!(
out,
" {direction} property <{slint_type}> {name}: {value};"
)
.unwrap();
first = false;
}
Ok(())
@@ -278,9 +298,9 @@ fn parse_color(value: &Value) -> Result<String, String> {
let hex = value
.as_str()
.ok_or("must be a quoted hex colour such as \"#1B1C1E\"")?;
let digits = hex.strip_prefix('#').ok_or_else(|| {
format!("`{hex}` is not a hex colour; expected a leading `#`")
})?;
let digits = hex
.strip_prefix('#')
.ok_or_else(|| format!("`{hex}` is not a hex colour; expected a leading `#`"))?;
if !matches!(digits.len(), 3 | 4 | 6 | 8) || !digits.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!(
"`{hex}` is not a hex colour; expected #RGB, #RGBA, #RRGGBB or #RRGGBBAA"
+53 -14
View File
@@ -219,7 +219,11 @@ pub fn apply_press(
selection.clear();
}
let (lo, hi) = if from <= row { (from, row) } else { (row, from) };
let (lo, hi) = if from <= row {
(from, row)
} else {
(row, from)
};
let hi = hi.min(ids.len().saturating_sub(1));
for id in &ids[lo..=hi] {
selection.insert(*id);
@@ -290,7 +294,8 @@ pub fn refresh_tree(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog
// Deep counts are one query per row. That is fine at sidebar scale and
// wrong at grid scale, which is why the grid does not do this.
let deep = coll::deep_count(catalog.connection(), id).unwrap_or(row.collection.direct_count);
let deep =
coll::deep_count(catalog.connection(), id).unwrap_or(row.collection.direct_count);
ids.push(id);
smart.push(row.collection.kind == CollectionKind::Smart);
@@ -348,8 +353,7 @@ fn compose_drag_image(thumbs: &[slint::Image]) -> slint::Image {
let tw = ((size.width as f32 * scale) as u32).max(1);
let th = ((size.height as f32 * scale) as u32).max(1);
let mut canvas =
slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(tw + offset, th + offset);
let mut canvas = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(tw + offset, th + offset);
let cw = canvas.width();
let stride = cw as usize;
let pixels = canvas.make_mut_slice();
@@ -670,7 +674,12 @@ fn start_trash(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
session: &Rc<dyn Fn() -> Option<(dr_sync_nextcloud::AppCredentials, dr_sync_nextcloud::Session)>>,
session: &Rc<
dyn Fn() -> Option<(
dr_sync_nextcloud::AppCredentials,
dr_sync_nextcloud::Session,
)>,
>,
images: &[ImageId],
reload: &Rc<dyn Fn()>,
) {
@@ -735,7 +744,12 @@ fn start_restore(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
session: &Rc<dyn Fn() -> Option<(dr_sync_nextcloud::AppCredentials, dr_sync_nextcloud::Session)>>,
session: &Rc<
dyn Fn() -> Option<(
dr_sync_nextcloud::AppCredentials,
dr_sync_nextcloud::Session,
)>,
>,
images: &[ImageId],
reload: &Rc<dyn Fn()>,
) {
@@ -823,7 +837,11 @@ fn drain_trash(
};
match msg {
TrashMessage::Progress { done, total, failed } => {
TrashMessage::Progress {
done,
total,
failed,
} => {
let status = if failed > 0 {
format!("{done} / {total} · {failed} failed")
} else {
@@ -925,7 +943,10 @@ pub fn wire<S, R, C>(
) where
S: Fn() + 'static,
R: Fn() -> Vec<ImageId> + 'static,
C: Fn() -> Option<(dr_sync_nextcloud::AppCredentials, dr_sync_nextcloud::Session)> + 'static,
C: Fn() -> Option<(
dr_sync_nextcloud::AppCredentials,
dr_sync_nextcloud::Session,
)> + 'static,
{
// Coerced to trait objects here rather than at each use: `start_trash` and
// `drain_trash` are shared by three callbacks, and a generic parameter would
@@ -933,7 +954,10 @@ pub fn wire<S, R, C>(
let on_scope_changed: Rc<dyn Fn()> = Rc::new(on_scope_changed);
let visible_ids = Rc::new(visible_ids);
let session: Rc<
dyn Fn() -> Option<(dr_sync_nextcloud::AppCredentials, dr_sync_nextcloud::Session)>,
dyn Fn() -> Option<(
dr_sync_nextcloud::AppCredentials,
dr_sync_nextcloud::Session,
)>,
> = Rc::new(session);
// --- selection ---------------------------------------------------------
@@ -1310,7 +1334,13 @@ pub fn wire<S, R, C>(
crate::library::thumbs_dir(&sess.server, &sess.user_id),
);
drain_trash(w.as_weak(), ctl.clone(), catalog.clone(), rx, reload.clone());
drain_trash(
w.as_weak(),
ctl.clone(),
catalog.clone(),
rx,
reload.clone(),
);
});
}
@@ -1702,7 +1732,7 @@ fn unique_name(conn: &rusqlite::Connection, parent: Option<CollectionId>) -> Str
}
for n in 2..1000 {
let candidate = format!("{base} {n}");
if !taken.iter().any(|t| *t == candidate) {
if !taken.contains(&candidate) {
return candidate;
}
}
@@ -1948,7 +1978,11 @@ mod tests {
}
/// The spring's inputs: rows, which have children, and which are collapsed.
fn spring_fixture() -> (Vec<CollectionId>, Vec<bool>, std::collections::HashSet<CollectionId>) {
fn spring_fixture() -> (
Vec<CollectionId>,
Vec<bool>,
std::collections::HashSet<CollectionId>,
) {
let ids = vec![CollectionId(1), CollectionId(2), CollectionId(3)];
// 1 is a collapsed parent, 2 an expanded parent, 3 a leaf.
let has_children = vec![true, true, false];
@@ -2000,11 +2034,16 @@ mod tests {
// A tree that flaps open under every passing pointer is worse than one
// that never opens. This pins the intent rather than the number: a
// reflex-speed value here would be a regression, not a tuning choice.
assert!(
// Asserted in a const item rather than at runtime: the condition is
// constant either way, and clippy is right that a runtime assert on it
// is theatre. This form fails the build instead of a test run, which is
// strictly earlier, and keeps the bound where a reader of the constant
// will look for it.
const _: () = assert!(
SPRING_DELAY_MS >= 300,
"a pointer crossing a parent must not open it"
);
assert!(
const _: () = assert!(
SPRING_DELAY_MS <= 900,
"and a deliberate dwell must not feel like a hang"
);
+1 -1
View File
@@ -225,7 +225,7 @@ async fn sync_shards(
let have: std::collections::HashSet<u32> = local.iter().map(|s| s.id).collect();
let mut store = store;
for (name, _) in &remote {
for name in remote.keys() {
let Some(id) = shard_id(name) else { continue };
if have.contains(&id) {
continue;
+23 -9
View File
@@ -491,20 +491,29 @@ impl DevelopSession {
/// Mirror horizontally, about the frame's vertical centre line.
pub fn toggle_flip_h(&mut self) {
let (h, _) = self.graph.framing().flips();
self.graph
.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::FLIP_H, f32::from(u8::from(!h)));
self.graph.set_param(
dr_pipeline::framing::ID,
dr_pipeline::framing::FLIP_H,
f32::from(u8::from(!h)),
);
}
pub fn toggle_flip_v(&mut self) {
let (_, v) = self.graph.framing().flips();
self.graph
.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::FLIP_V, f32::from(u8::from(!v)));
self.graph.set_param(
dr_pipeline::framing::ID,
dr_pipeline::framing::FLIP_V,
f32::from(u8::from(!v)),
);
}
/// Set the straightening angle, in degrees.
pub fn set_angle(&mut self, degrees: f32) {
self.graph
.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE, degrees);
self.graph.set_param(
dr_pipeline::framing::ID,
dr_pipeline::framing::ANGLE,
degrees,
);
}
/// Whether the framing currently changes the image — what lights the
@@ -586,7 +595,11 @@ impl DevelopSession {
/// Pan by a fraction of the *visible* area — what a drag reports.
pub fn pan_by(&mut self, dx: f32, dy: f32) {
let view = self.graph.framing().view();
self.set_view_clamped(view.x + dx * view.width, view.y + dy * view.height, view.width);
self.set_view_clamped(
view.x + dx * view.width,
view.y + dy * view.height,
view.width,
);
}
/// Back to fitting the whole frame.
@@ -923,8 +936,9 @@ mod tests {
"group at {head} reaches past the model"
);
// Every row the group spans must agree it belongs to that group.
for span in head..head + len {
assert_eq!(rows[span].0, head, "row {span} disagrees about its group");
for (offset, spanned) in rows[head..head + len].iter().enumerate() {
let span = head + offset;
assert_eq!(spanned.0, head, "row {span} disagrees about its group");
}
}
}
-1
View File
@@ -36,7 +36,6 @@ impl LaunchController {
poll_timer: RefCell::new(None),
})
}
}
/// Push the model into the window's properties.
+159 -103
View File
@@ -109,7 +109,10 @@ pub enum ThumbnailMessage {
Ready(Box<ThumbnailReady>),
/// No preview could be extracted. The cell stays a placeholder rather than
/// silently retrying forever.
Unavailable { row: usize, reason: String },
Unavailable {
row: usize,
reason: String,
},
/// How the batch split between the store and the network.
///
/// Sent once, before any fetch. Without it there is no way to tell a
@@ -139,7 +142,9 @@ pub enum ThumbnailMessage {
/// connection. Sent at most once per batch, because a dropped connection
/// produces one of these per *cell* otherwise and the banner would be
/// rewritten sixty times.
Offline { reason: String },
Offline {
reason: String,
},
}
/// TRACES: FR-CAT-15 | FR-CAT-11
@@ -337,8 +342,7 @@ pub fn spawn_sidecar_writes(
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let rt = match crate::net_runtime::build()
{
let rt = match crate::net_runtime::build() {
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(SidecarMessage::Finished {
@@ -402,10 +406,7 @@ pub enum SidecarMessage {
}
/// Read-modify-write one sidecar.
async fn write_one_sidecar(
backend: &NextcloudBackend,
w: &JudgementWrite,
) -> Result<(), String> {
async fn write_one_sidecar(backend: &NextcloudBackend, w: &JudgementWrite) -> Result<(), String> {
let path = RemotePath::new(sidecar_path(&w.image_path));
let id = RemoteId::Path(path.clone());
@@ -421,7 +422,10 @@ async fn write_one_sidecar(
// destroy an edit this build merely failed to understand. The
// judgement stays in the catalog and the file is left alone.
Err(e) => {
log::warn!("sidecar at {} is unreadable ({e}); not overwriting", path.as_str());
log::warn!(
"sidecar at {} is unreadable ({e}); not overwriting",
path.as_str()
);
None
}
})
@@ -565,8 +569,7 @@ fn run_scan(
}
let catalog = Catalog::open(&catalog_path).map_err(ScanFailure::local)?;
let rt = crate::net_runtime::build()
.map_err(ScanFailure::local)?;
let rt = crate::net_runtime::build().map_err(ScanFailure::local)?;
rt.block_on(async {
let backend = NextcloudBackend::new(&creds, &user_id).map_err(ScanFailure::local)?;
@@ -576,19 +579,13 @@ fn run_scan(
// it is what keeps cost proportional to what changed (ARCH §8.4).
let known = load_folder_etags(&catalog, &root);
let result = dr_sync::scan(
&backend,
&RemotePath::new(&root),
&filter,
&known,
|p| {
let _ = tx.send(ScanMessage::Progress {
directories: p.directories_listed,
pruned: p.directories_pruned,
images: p.images_found,
});
},
)
let result = dr_sync::scan(&backend, &RemotePath::new(&root), &filter, &known, |p| {
let _ = tx.send(ScanMessage::Progress {
directories: p.directories_listed,
pruned: p.directories_pruned,
images: p.images_found,
});
})
.await?;
persist(&catalog, &root, &result).map_err(ScanFailure::local)?;
@@ -818,12 +815,22 @@ impl std::fmt::Display for FetchFailure {
#[derive(Debug)]
pub enum PinMessage {
/// How many originals the pin still needs. Sent once, before any transfer.
Planned { total: usize },
Planned {
total: usize,
},
/// One original landed.
Stored { done: usize },
Stored {
done: usize,
},
/// The pin is fully downloaded.
Done { stored: usize, bytes: u64 },
Failed { message: String, offline: bool },
Done {
stored: usize,
bytes: u64,
},
Failed {
message: String,
offline: bool,
},
}
/// TRACES: FR-NC-6a
@@ -890,8 +897,7 @@ pub fn spawn_pin_fetch(
return;
}
let rt = match crate::net_runtime::build()
{
let rt = match crate::net_runtime::build() {
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(PinMessage::Failed {
@@ -1056,8 +1062,7 @@ pub fn spawn_full_fetch(
}
}
let rt = match crate::net_runtime::build()
{
let rt = match crate::net_runtime::build() {
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(Err(FetchFailure::local(e)));
@@ -1202,8 +1207,7 @@ pub fn spawn_thumbnails(
return;
}
let rt = match crate::net_runtime::build()
{
let rt = match crate::net_runtime::build() {
Ok(rt) => rt,
Err(e) => {
for req in &to_fetch {
@@ -1487,10 +1491,7 @@ async fn read_metadata_only(
// Backing off at all matters more than the exact interval: the
// contention that produced the lock is our own lanes, so any
// pause lets the holder finish.
tokio::time::sleep(std::time::Duration::from_millis(
200 * attempt as u64,
))
.await;
tokio::time::sleep(std::time::Duration::from_millis(200 * attempt as u64)).await;
}
Err(e) => {
// Not surfaced: a missing date leaves the image off the
@@ -1560,11 +1561,15 @@ pub enum SweepMessage {
/// How many images still need work, counted once at the start.
Total(usize),
/// Another chunk finished. Carries cumulative counts.
Progress { done: usize, dated: usize },
Finished { dated: usize },
Progress {
done: usize,
dated: usize,
},
Finished {
dated: usize,
},
}
/// Await every future concurrently, returning results in order.
///
/// A hand-rolled `join_all` rather than a `futures` dependency for one
@@ -1658,7 +1663,10 @@ pub fn spawn_sweep(
// Silent failure here left the sweep looking like it had run
// and found nothing: no progress, no error, 17,397 images
// still unindexed.
log::warn!("sweep: cannot open catalog at {}: {e}", catalog_path.display());
log::warn!(
"sweep: cannot open catalog at {}: {e}",
catalog_path.display()
);
let _ = tx.send(SweepMessage::Finished { dated: 0 });
return;
}
@@ -1674,8 +1682,7 @@ pub fn spawn_sweep(
return;
}
let rt = match crate::net_runtime::build()
{
let rt = match crate::net_runtime::build() {
Ok(rt) => rt,
Err(e) => {
log::warn!("sweep: no runtime: {e}");
@@ -1759,18 +1766,21 @@ pub fn spawn_sweep(
// Only files actually read. One that could not be fetched is
// left alone so the next pass retries it, rather than being
// written off over a lock or a dropped connection.
found.extend(chunk.iter().filter(|r| {
reached.contains(&r.image_id) && !answered.contains(&r.image_id)
}).map(
|r| MetadataFound {
image_id: r.image_id,
captured_at: None,
captured_offset: None,
camera: None,
lens: None,
iso: None,
},
));
found.extend(
chunk
.iter()
.filter(|r| {
reached.contains(&r.image_id) && !answered.contains(&r.image_id)
})
.map(|r| MetadataFound {
image_id: r.image_id,
captured_at: None,
captured_offset: None,
camera: None,
lens: None,
iso: None,
}),
);
dated += found.iter().filter(|m| m.captured_at.is_some()).count();
let read = answered.len();
@@ -1865,10 +1875,9 @@ fn flush_sweep(catalog: &Catalog, found: &mut Vec<MetadataFound>) {
.map(|m| m.image_id)
.collect();
for id in ids {
let _ = catalog.connection().execute(
"UPDATE images SET metadata_state = 2 WHERE id = ?1",
[id],
);
let _ = catalog
.connection()
.execute("UPDATE images SET metadata_state = 2 WHERE id = ?1", [id]);
}
found.clear();
}
@@ -2090,11 +2099,12 @@ pub fn total_images_scoped(
.iter()
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
.collect();
let n: i64 = catalog.connection().query_row(
&sql,
rusqlite::params_from_iter(params.iter()),
|r| r.get(0),
)?;
let n: i64 =
catalog
.connection()
.query_row(&sql, rusqlite::params_from_iter(params.iter()), |r| {
r.get(0)
})?;
Ok(n as usize)
}
@@ -2138,13 +2148,11 @@ pub fn local_original_count(catalog: &Catalog) -> Result<usize, dr_catalog::Cata
/// What the scan reports and what the sidebar's "all images" row shows — the
/// size of the library itself, not of the current view.
pub fn total_images(catalog: &Catalog) -> Result<usize, dr_catalog::CatalogError> {
let n: i64 = catalog
.connection()
.query_row(
&format!("SELECT count(*) FROM images WHERE {VISIBLE_UNALIASED}"),
[],
|r| r.get(0),
)?;
let n: i64 = catalog.connection().query_row(
&format!("SELECT count(*) FROM images WHERE {VISIBLE_UNALIASED}"),
[],
|r| r.get(0),
)?;
Ok(n as usize)
}
@@ -2160,14 +2168,12 @@ mod tests {
use super::*;
use dr_sync::RemoteEntry;
#[test]
fn join_all_preserves_order_regardless_of_completion() {
// The ordering guarantee is what lets a caller pair results back to
// their inputs; without it a lane's dates could be attributed to the
// wrong images.
let rt = crate::net_runtime::build()
.unwrap();
let rt = crate::net_runtime::build().unwrap();
let out = rt.block_on(async {
futures_join_all(vec![
@@ -2190,8 +2196,7 @@ mod tests {
#[test]
fn join_all_of_nothing_completes() {
let rt = crate::net_runtime::build()
.unwrap();
let rt = crate::net_runtime::build().unwrap();
let out: Vec<i32> =
rt.block_on(async { futures_join_all(Vec::<std::future::Ready<i32>>::new()).await });
assert!(out.is_empty());
@@ -2226,7 +2231,6 @@ mod tests {
assert_eq!(seen, chunk);
}
#[test]
fn a_scrub_ordinal_matches_the_grid_position() {
// The scrub's count and the grid's window must use *identical*
@@ -2319,9 +2323,11 @@ mod tests {
// Folder ETags must persist or the next scan prunes nothing.
let etag: String = catalog
.connection()
.query_row("SELECT etag FROM folders WHERE path = 'PhotosRaw'", [], |r| {
r.get(0)
})
.query_row(
"SELECT etag FROM folders WHERE path = 'PhotosRaw'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(etag, "e1");
}
@@ -2428,7 +2434,10 @@ mod tests {
images: vec![],
directories: vec![
(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1")),
(RemotePath::new("PhotosRaw/2026"), dr_sync::Validator::new("e2")),
(
RemotePath::new("PhotosRaw/2026"),
dr_sync::Validator::new("e2"),
),
],
progress: Default::default(),
};
@@ -2437,7 +2446,9 @@ mod tests {
let known = load_folder_etags(&catalog, "PhotosRaw");
assert_eq!(known.len(), 2);
assert_eq!(
known.get(&RemotePath::new("PhotosRaw/2026")).map(|v| v.as_str()),
known
.get(&RemotePath::new("PhotosRaw/2026"))
.map(|v| v.as_str()),
Some("e2")
);
}
@@ -2492,15 +2503,26 @@ mod tests {
let catalog = with_images(10);
let ids = image_ids(&catalog);
let c = coll::create(catalog.connection(), "Selects", None, CollectionKind::Manual)
.unwrap();
let c = coll::create(
catalog.connection(),
"Selects",
None,
CollectionKind::Manual,
)
.unwrap();
coll::add_images(catalog.connection(), c, &ids[2..5]).unwrap();
let cells = read_cells_scoped(&catalog, Some(c), &RatingFilter::default(), 0, 120).unwrap();
assert_eq!(cells.len(), 3);
assert_eq!(total_images_scoped(&catalog, Some(c), &RatingFilter::default()).unwrap(), 3);
assert_eq!(
total_images_scoped(&catalog, Some(c), &RatingFilter::default()).unwrap(),
3
);
// Unscoped is still the whole library.
assert_eq!(total_images_scoped(&catalog, None, &RatingFilter::default()).unwrap(), 10);
assert_eq!(
total_images_scoped(&catalog, None, &RatingFilter::default()).unwrap(),
10
);
}
#[test]
@@ -2523,9 +2545,13 @@ mod tests {
coll::add_images(catalog.connection(), iceland, &ids[0..4]).unwrap();
// The parent itself has no direct members at all.
let cells = read_cells_scoped(&catalog, Some(trips), &RatingFilter::default(), 0, 120).unwrap();
let cells =
read_cells_scoped(&catalog, Some(trips), &RatingFilter::default(), 0, 120).unwrap();
assert_eq!(cells.len(), 4, "the set shows what its children hold");
assert_eq!(total_images_scoped(&catalog, Some(trips), &RatingFilter::default()).unwrap(), 4);
assert_eq!(
total_images_scoped(&catalog, Some(trips), &RatingFilter::default()).unwrap(),
4
);
}
#[test]
@@ -2548,9 +2574,13 @@ mod tests {
coll::add_images(catalog.connection(), trips, &ids[0..2]).unwrap();
coll::add_images(catalog.connection(), iceland, &ids[0..3]).unwrap();
let cells = read_cells_scoped(&catalog, Some(trips), &RatingFilter::default(), 0, 120).unwrap();
let cells =
read_cells_scoped(&catalog, Some(trips), &RatingFilter::default(), 0, 120).unwrap();
assert_eq!(cells.len(), 3, "images 0..3, each once");
assert_eq!(total_images_scoped(&catalog, Some(trips), &RatingFilter::default()).unwrap(), 3);
assert_eq!(
total_images_scoped(&catalog, Some(trips), &RatingFilter::default()).unwrap(),
3
);
}
#[test]
@@ -2578,8 +2608,15 @@ mod tests {
let catalog = with_images(10);
let c = coll::create(catalog.connection(), "Empty", None, CollectionKind::Manual).unwrap();
assert!(read_cells_scoped(&catalog, Some(c), &RatingFilter::default(), 0, 120).unwrap().is_empty());
assert_eq!(total_images_scoped(&catalog, Some(c), &RatingFilter::default()).unwrap(), 0);
assert!(
read_cells_scoped(&catalog, Some(c), &RatingFilter::default(), 0, 120)
.unwrap()
.is_empty()
);
assert_eq!(
total_images_scoped(&catalog, Some(c), &RatingFilter::default()).unwrap(),
0
);
}
#[test]
@@ -2612,7 +2649,10 @@ mod tests {
let mut store = ThumbStore::open(&dir).unwrap();
let bytes = dr_thumbs::encode_rgba(32, 32, &rgba).unwrap();
store
.put(4242, dr_thumbs::ThumbSize::Grid, &dr_thumbs::Thumbnail {
.put(
4242,
dr_thumbs::ThumbSize::Grid,
&dr_thumbs::Thumbnail {
width: 32,
height: 32,
bytes,
@@ -2622,7 +2662,10 @@ mod tests {
}
let store = ThumbStore::open(&dir).unwrap();
let stored = store.get(4242, dr_thumbs::ThumbSize::Grid).unwrap().expect("persisted");
let stored = store
.get(4242, dr_thumbs::ThumbSize::Grid)
.unwrap()
.expect("persisted");
let (w, h, out) = dr_thumbs::decode_rgba(&stored.bytes).unwrap();
assert_eq!((w, h), (32, 32));
// Lossy, so compare approximately — a blue-ish pixel must stay blue.
@@ -2645,17 +2688,24 @@ mod tests {
let mut store = ThumbStore::open(&dir).unwrap();
let bytes = dr_thumbs::encode_rgba(8, 8, &rgba).unwrap();
store
.put(99, dr_thumbs::ThumbSize::Grid, &dr_thumbs::Thumbnail {
width: 8,
height: 8,
bytes,
})
.put(
99,
dr_thumbs::ThumbSize::Grid,
&dr_thumbs::Thumbnail {
width: 8,
height: 8,
bytes,
},
)
.unwrap();
// The split in `spawn_thumbnails`: a hit decodes off local disk and is
// reported with `from_cache` set, which is what the reachability gate
// keys on.
let stored = store.get(99, dr_thumbs::ThumbSize::Grid).unwrap().expect("stored");
let stored = store
.get(99, dr_thumbs::ThumbSize::Grid)
.unwrap()
.expect("stored");
let (width, height, rgba) = dr_thumbs::decode_rgba(&stored.bytes).unwrap();
let hit = ThumbnailReady {
row: 0,
@@ -2824,7 +2874,11 @@ mod tests {
// must not try to draw itself in a single query.
let catalog = scanned(6);
for i in 1..=6 {
trash_at(&catalog, &format!("PhotosRaw/IMG_{i:04}.CR2"), 100 + i as i64);
trash_at(
&catalog,
&format!("PhotosRaw/IMG_{i:04}.CR2"),
100 + i as i64,
);
}
let first = read_trashed_cells(&catalog, 0, 2).unwrap();
@@ -2832,7 +2886,9 @@ mod tests {
assert_eq!(first.len(), 2);
assert_eq!(second.len(), 2);
assert!(
first.iter().all(|a| !second.iter().any(|b| b.image_id == a.image_id)),
first
.iter()
.all(|a| !second.iter().any(|b| b.image_id == a.image_id)),
"pages must not overlap"
);
}
+43 -41
View File
@@ -268,7 +268,9 @@ impl LibraryController {
// applies to every fetch from now on.
let Some(dir) = self.cache_dir() else { return };
let borrow = self.catalog.borrow();
let Some(catalog) = borrow.as_ref() else { return };
let Some(catalog) = borrow.as_ref() else {
return;
};
match dr_catalog::Cache::open(&dir, budget)
.and_then(|cache| cache.enforce(catalog.connection()))
{
@@ -568,9 +570,7 @@ fn drain_scan(
// off, and hiding them makes an incremental rescan look
// identical to a full one.
let status = if pruned > 0 {
format!(
"{directories} folders · {pruned} unchanged · {images} images"
)
format!("{directories} folders · {pruned} unchanged · {images} images")
} else {
format!("{directories} folders · {images} images")
};
@@ -628,9 +628,7 @@ fn drain_scan(
// than the part that was scrolled past.
start_sweep(&w, ctl);
}
Err(e) => {
w.set_library_error(format!("opening catalog: {e}").into())
}
Err(e) => w.set_library_error(format!("opening catalog: {e}").into()),
}
stop(&ctl.scan_timer);
return;
@@ -1166,8 +1164,7 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
*ctl.file_ids.borrow_mut() = cells.iter().map(|c| c.file_id).collect();
*ctl.sizes.borrow_mut() = cells.iter().map(|c| c.size).collect();
*ctl.image_ids.borrow_mut() = cells.iter().map(|c| c.image_id).collect();
*ctl.needs_metadata.borrow_mut() =
cells.iter().map(|c| c.metadata_state < 2).collect();
*ctl.needs_metadata.borrow_mut() = cells.iter().map(|c| c.metadata_state < 2).collect();
ctl.requested.borrow_mut().clear();
// The model is about to be replaced, so every thumbnail still in flight
// addresses a window that no longer exists. Bumping here — before the swap,
@@ -1320,11 +1317,7 @@ fn apply_judgement(
}
/// What the status line says about a judgement that just landed.
fn judgement_summary(
n: usize,
rating: Option<u8>,
flag: Option<dr_types::FlagState>,
) -> String {
fn judgement_summary(n: usize, rating: Option<u8>, flag: Option<dr_types::FlagState>) -> String {
let what = match (rating, flag) {
(Some(0), _) => "unrated".to_string(),
(Some(r), _) => format!("{r} star{}", if r == 1 { "" } else { "s" }),
@@ -1613,9 +1606,7 @@ fn drain_thumbnails(
// Date reads produce no cell, so they are counted into
// the bar's denominator or it finishes while work is
// still running.
w.set_library_thumbs_total(
w.get_library_thumbs_total() + dating as i32,
);
w.set_library_thumbs_total(w.get_library_thumbs_total() + dating as i32);
let mut parts = Vec::new();
if cached > 0 {
@@ -1820,7 +1811,11 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
report.shards_uploaded,
report.shards_downloaded,
report.thumbnails_adopted,
if report.catalog_uploaded { "pushed" } else { "not pushed" },
if report.catalog_uploaded {
"pushed"
} else {
"not pushed"
},
if report.collections_gained > 0 {
format!(", {} collection(s) gained", report.collections_gained)
} else {
@@ -2045,9 +2040,8 @@ fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc<LibraryContr
window.set_library_current_bucket(current.unwrap_or(0) as i32);
window.set_library_current_fraction(fraction);
window.set_library_timeline_anchored(current.is_some());
window.set_library_timeline_label(
format!("{} – {}", format_date(from), format_date(to)).into(),
);
window
.set_library_timeline_label(format!("{} – {}", format_date(from), format_date(to)).into());
window.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(bars)));
}
@@ -2264,8 +2258,7 @@ pub fn wire<F>(
ctl: Rc<LibraryController>,
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
on_open_image: F,
)
where
) where
F: Fn(String) + 'static,
{
{
@@ -2435,8 +2428,8 @@ where
// Reacting to every scroll event would re-query and re-fetch
// continuously during a drag; this fires a few times per screenful.
let margin = window_size / 4;
let inside = first_visible >= current + margin
&& first_visible + margin < current + window_size;
let inside =
first_visible >= current + margin && first_visible + margin < current + window_size;
if inside {
return;
}
@@ -2453,8 +2446,12 @@ where
window.on_library_timeline_pan(move |fraction| {
let Some(w) = weak.upgrade() else { return };
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else { return };
let Some(full) = catalog_span(catalog) else { return };
let Some(catalog) = borrow.as_ref() else {
return;
};
let Some(full) = catalog_span(catalog) else {
return;
};
// A fraction of the *visible* span, so dragging half the axis
// moves half a span's worth of time whatever the zoom — and a
@@ -2466,11 +2463,7 @@ where
return;
}
let centre = ctl
.timeline_centre
.borrow()
.unwrap_or((from + to) / 2)
+ shift;
let centre = ctl.timeline_centre.borrow().unwrap_or((from + to) / 2) + shift;
*ctl.timeline_centre.borrow_mut() = Some(centre.clamp(full.0, full.1));
refresh_timeline(&w, catalog, &ctl);
});
@@ -2501,8 +2494,12 @@ where
window.on_library_scrub_fraction(move |f| {
let Some(w) = weak.upgrade() else { return };
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else { return };
let Some(full) = catalog_span(catalog) else { return };
let Some(catalog) = borrow.as_ref() else {
return;
};
let Some(full) = catalog_span(catalog) else {
return;
};
let (from, to) = zoomed_span(
full,
@@ -2784,8 +2781,6 @@ fn stop(slot: &RefCell<Option<slint::Timer>>) {
mod tests {
use super::*;
/// The instant a scrub fraction names within a span, as the handler
/// computes it.
fn instant_at(span: (i64, i64), f: f32) -> i64 {
@@ -2903,7 +2898,6 @@ mod tests {
assert_eq!(pinch_step(&mut accum, 0.72), 0, "back roughly to 1.0");
}
/// One zoom step, as the handler applies it.
fn zoom_cell(current: f32, delta: i32) -> f32 {
let next = if delta > 0 {
@@ -2920,7 +2914,10 @@ mod tests {
// step is imperceptible at 400px and violent at 90px.
let a = zoom_cell(180.0, 1);
assert!((a - 225.0).abs() < 0.01);
assert!((zoom_cell(a, -1) - 180.0).abs() < 0.01, "in then out returns");
assert!(
(zoom_cell(a, -1) - 180.0).abs() < 0.01,
"in then out returns"
);
}
#[test]
@@ -2943,12 +2940,17 @@ mod tests {
// The point of the second class: past 256px a grid thumbnail is being
// upscaled, and the softness shows.
assert_eq!(ThumbSize::for_cell(180), ThumbSize::Grid);
assert_eq!(ThumbSize::for_cell(zoom_cell(225.0, 1) as u32), ThumbSize::Large);
assert_eq!(
ThumbSize::for_cell(zoom_cell(225.0, 1) as u32),
ThumbSize::Large
);
// And zooming back down does not keep paying for it.
assert_eq!(ThumbSize::for_cell(zoom_cell(281.0, -1) as u32), ThumbSize::Grid);
assert_eq!(
ThumbSize::for_cell(zoom_cell(281.0, -1) as u32),
ThumbSize::Grid
);
}
#[test]
fn a_request_key_survives_the_window_moving() {
use dr_thumbs::ThumbSize;
+2 -12
View File
@@ -151,10 +151,7 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
window.set_settings_allow_upscaling(s.export.allow_upscaling);
window.set_settings_sharpening_labels(labels(OutputSharpening::ALL.iter().map(|x| x.label())));
window.set_settings_sharpening_selected(index_of(
&OutputSharpening::ALL,
&s.export.sharpening,
));
window.set_settings_sharpening_selected(index_of(&OutputSharpening::ALL, &s.export.sharpening));
window.set_settings_filename_template(s.export.filename_template.clone().into());
window.set_settings_collision_labels(labels(CollisionPolicy::ALL.iter().map(|c| c.label())));
@@ -163,14 +160,7 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
window.set_settings_destination(s.export.destination.clone().into());
window.set_settings_error(
controller
.error
.borrow()
.clone()
.unwrap_or_default()
.into(),
);
window.set_settings_error(controller.error.borrow().clone().unwrap_or_default().into());
}
/// A label list as a Slint model.
+6 -9
View File
@@ -48,10 +48,7 @@ pub enum TrashMessage {
failed: usize,
},
/// The batch finished. `failed` names what did not work, for the status line.
Done {
moved: usize,
failed: Vec<String>,
},
Done { moved: usize, failed: Vec<String> },
}
/// Which way an image is being moved.
@@ -175,8 +172,7 @@ pub fn spawn_move(
std::thread::spawn(move || {
let total = moves.len();
let rt = match crate::net_runtime::build()
{
let rt = match crate::net_runtime::build() {
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(TrashMessage::Done {
@@ -289,8 +285,7 @@ pub fn spawn_purge(
std::thread::spawn(move || {
let total = paths.len();
let rt = match crate::net_runtime::build()
{
let rt = match crate::net_runtime::build() {
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(TrashMessage::Done {
@@ -486,7 +481,9 @@ mod tests {
fn a_missing_image_is_skipped_rather_than_planned_against_nothing() {
// The grid's selection can outlive a rescan that removed a row.
let cat = seeded();
assert!(plan_trash(&cat, "PhotosRaw", &[img(999)]).unwrap().is_empty());
assert!(plan_trash(&cat, "PhotosRaw", &[img(999)])
.unwrap()
.is_empty());
assert!(plan_restore(&cat, &[img(999)]).unwrap().is_empty());
}