Merge master into tablet-selection

Two real conflicts, both from work that landed either side of the same
lines rather than against them.

`lib.rs`: the settings controller was hoisted above the People screen's
wiring, and Android's thumbnail-tier eviction registered itself at the
same point. Independent, so both stay.

`library.rs`: manual collection ordering and burst folding each added a
clause to the same two queries. The scoped range read now carries both —
the folding matters there for one step further on than it does in the
grid, because a collapsed burst is one cell, so an ordinal counted over a
list still holding every frame names a photograph several places away
from the one the user pointed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 22:52:42 +02:00
co-authored by Claude Opus 5
54 changed files with 7566 additions and 166 deletions
+531
View File
@@ -0,0 +1,531 @@
//! TRACES: FR-CULL-5
//! Burst grouping, as the library screen uses it.
//!
//! [`dr_catalog::bursts`] holds the grouping itself and knows nothing about
//! pixels. This is the other half: where the similarity signal comes from, and
//! how a group reaches a grid cell.
//!
//! # The signal comes out of the thumbnail store
//!
//! A perceptual signature needs pixels, and the cheapest pixels in the app are
//! the ones already sitting in `dr-thumbs`: a 256px JPEG per photograph, built
//! for the grid, shared between devices, and vastly more resolution than a 9×8
//! reduction can use. So this pass decodes thumbnails, never originals. A
//! library that has been browsed — or that has synced somebody else's shards —
//! has already paid for every signature it is about to get.
//!
//! The consequence, stated rather than hidden: **an image with no thumbnail
//! gets no signature, and a frame with no signature never joins a burst.** That
//! is self-correcting rather than permanent — the next pass finds the thumbnail
//! the sweep has since built — and it is the reason this runs when the
//! thumbnail sweep finishes rather than on a timer.
//!
//! # Why a pass and not a job
//!
//! Hashing is per-image and would make a perfectly good job kind. Grouping is
//! not: a burst is a property of a *run* of frames, so a per-image job would
//! regroup the library once per photograph. Since the two have to happen in that
//! order and the second cannot be split, both live in one pass — the same
//! argument docs/catalog.md §10.2 makes for face clustering.
//!
//! # It is never on the UI thread
//!
//! Decoding tens of thousands of thumbnails is bounded only by library size, and
//! the one thing that must not grow with library size is how long the window
//! stops answering (NFR-P9). Cancellation is dropping the receiver; a pass
//! abandoned half way leaves the signatures it did compute — they are permanent
//! and correct — and the previous grouping intact.
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::mpsc::Receiver;
use dr_catalog::bursts::{self, Rules, Signature};
use dr_catalog::Catalog;
use dr_thumbs::{ThumbSize, ThumbStore};
use dr_types::ImageId;
use slint::Model as _;
use crate::AppWindow;
/// How many signatures are written per transaction.
///
/// One transaction per image costs a WAL commit per thumbnail and turns a pass
/// over a real library into minutes of fsync; one transaction for the whole pass
/// holds a write lock for the duration and loses everything if the app closes.
/// A few hundred is the usual answer to that trade.
const WRITE_BATCH: usize = 256;
/// Progress from a grouping pass.
#[derive(Debug, Clone, PartialEq)]
pub enum BurstMessage {
/// How many images still need a signature. Sent once, before any decoding.
Started { to_hash: usize },
/// Cumulative signatures written.
Progress { hashed: usize },
/// The pass finished, and this is what the library now looks like.
Finished {
hashed: usize,
bursts: usize,
frames: usize,
},
/// It did not.
Failed(String),
}
/// Hash whatever is missing a signature, then rebuild the grouping.
///
/// Both halves run on a worker thread. The catalog is opened here rather than
/// shared with the UI's connection: SQLite connections are not `Send`, and WAL
/// is what makes a second one safe while the grid reads (NFR-R1).
pub fn spawn_grouping(catalog_path: PathBuf, thumbs_dir: PathBuf) -> Receiver<BurstMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let catalog = match Catalog::open(&catalog_path) {
Ok(c) => c,
Err(e) => {
let _ = tx.send(BurstMessage::Failed(format!("cannot open catalog: {e}")));
return;
}
};
let conn = catalog.connection();
let outstanding = match bursts::images_without_signature(conn) {
Ok(v) => v,
Err(e) => {
let _ = tx.send(BurstMessage::Failed(e.to_string()));
return;
}
};
if tx
.send(BurstMessage::Started {
to_hash: outstanding.len(),
})
.is_err()
{
return;
}
// A broken or absent store costs signatures, never correctness: the
// grouping still runs over whatever is already hashed, and the images
// that missed out are picked up by the next pass.
let store = match ThumbStore::open(&thumbs_dir) {
Ok(s) => Some(s),
Err(e) => {
log::warn!("thumbnail store unavailable, not hashing: {e}");
None
}
};
let hashed = match store {
Some(store) => hash_all(conn, &store, &outstanding, &tx),
None => 0,
};
match bursts::regroup(conn, Rules::default()) {
Ok(report) => {
log::info!(
"bursts: {hashed} signature(s) added, {} group(s) over {} frame(s), \
largest {}",
report.bursts,
report.frames,
report.largest
);
let _ = tx.send(BurstMessage::Finished {
hashed,
bursts: report.bursts,
frames: report.frames,
});
}
Err(e) => {
let _ = tx.send(BurstMessage::Failed(e.to_string()));
}
}
});
rx
}
thread_local! {
/// The running pass's drain timer, and whether one is running.
///
/// Module-local rather than a pair of fields on the library controller, so
/// that everything this feature needs to run lives in this file and the
/// screen that starts it is left holding nothing. Safe as a thread local
/// because Slint's event loop is single-threaded (NFR-P9) and this is only
/// ever touched from it.
///
/// The flag is separate because stopping a timer does not drop it: a slot
/// tested for emptiness would refuse every pass after the first.
static DRAIN: RefCell<Option<slint::Timer>> = const { RefCell::new(None) };
static RUNNING: Cell<bool> = const { Cell::new(false) };
}
/// Hash what has become hashable, then rebuild the library's burst grouping.
///
/// Fired when the thumbnail sweep finishes, because that is the moment the
/// signatures can all be computed: the pass reads thumbnails, and until the
/// sweep has run most images have none. Not on a timer, and not after every
/// scan — a regroup is cheap but not free, and nothing is waiting on it.
///
/// `grouped` is called once, with the number of bursts the library now has, if
/// the pass finishes. It is where the caller reloads the grid: the cells hold
/// the same photographs they held before — a new burst arrives open — but every
/// run of frames now carries a mark it did not have a moment ago, and only a
/// reload carries it.
///
/// Deliberately silent otherwise. The sweeps around it report progress because
/// they run for tens of minutes; this is seconds, and a status line for it would
/// be a line the user must read in order to learn nothing.
pub fn start_pass(catalog_path: PathBuf, thumbs_dir: PathBuf, grouped: impl Fn(usize) + 'static) {
// A second pass would read the same rows and write the same answer over the
// first one's transactions.
if RUNNING.get() {
return;
}
RUNNING.set(true);
let rx = spawn_grouping(catalog_path, thumbs_dir);
let timer = slint::Timer::default();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(400),
move || loop {
let msg = match rx.try_recv() {
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
finish();
return;
}
};
match msg {
BurstMessage::Started { to_hash } => {
log::info!("burst grouping: {to_hash} image(s) still to hash");
}
// Nothing on screen is showing this. Drained rather than
// ignored, because an unread channel is a worker that stalls.
BurstMessage::Progress { hashed } => {
log::debug!("burst grouping: {hashed} hashed so far");
}
BurstMessage::Finished {
hashed,
bursts,
frames,
} => {
log::info!(
"burst grouping: {hashed} signature(s) added, \
{bursts} group(s) over {frames} frame(s)"
);
finish();
grouped(bursts);
return;
}
BurstMessage::Failed(e) => {
log::warn!("burst grouping: {e}");
finish();
return;
}
}
},
);
DRAIN.with(|slot| *slot.borrow_mut() = Some(timer));
}
/// Stop draining, and let another pass start.
///
/// Stops the timer without dropping it — dropping one from inside its own
/// callback is not something to rely on — which is why the flag beside it is
/// what actually says whether a pass is running.
fn finish() {
RUNNING.set(false);
DRAIN.with(|slot| {
if let Some(timer) = slot.borrow().as_ref() {
timer.stop();
}
});
}
/// Decode each image's stored thumbnail and record its signature.
///
/// Returns how many were written. Anything without a stored thumbnail, or whose
/// blob will not decode, is simply skipped — it keeps its NULL and comes back
/// next time, which is the same treatment `spawn_thumbnails` gives a corrupt
/// blob.
fn hash_all(
conn: &rusqlite::Connection,
store: &ThumbStore,
outstanding: &[ImageId],
tx: &std::sync::mpsc::Sender<BurstMessage>,
) -> usize {
let keys = thumbnail_keys(conn);
let mut pending: Vec<(ImageId, Signature)> = Vec::with_capacity(WRITE_BATCH);
let mut written = 0usize;
for image in outstanding {
let Some(file_id) = keys.get(image).copied() else {
continue;
};
// The grid class, not the large one: 256px is already thirty times the
// detail the reduction keeps, and asking for `Large` would miss most of
// the store, which is filled at `Grid`.
let Ok(Some(thumb)) = store.get(file_id, ThumbSize::Grid) else {
continue;
};
let Ok((w, h, rgba)) = dr_thumbs::decode_rgba(&thumb.bytes) else {
continue;
};
let Some(signature) = bursts::signature_of_rgba(&rgba, w, h) else {
continue;
};
pending.push((*image, signature));
if pending.len() >= WRITE_BATCH {
written += flush(conn, &mut pending);
if tx.send(BurstMessage::Progress { hashed: written }).is_err() {
// The receiver is gone: the screen has moved on, and finishing
// the pass would be work nobody is waiting for.
return written;
}
}
}
written += flush(conn, &mut pending);
written
}
/// Write one batch of signatures, emptying `pending`.
fn flush(conn: &rusqlite::Connection, pending: &mut Vec<(ImageId, Signature)>) -> usize {
if pending.is_empty() {
return 0;
}
let n = pending.len();
let tx = match conn.unchecked_transaction() {
Ok(t) => t,
Err(e) => {
log::warn!("recording signatures: {e}");
pending.clear();
return 0;
}
};
for (image, signature) in pending.drain(..) {
if let Err(e) = bursts::set_signature(&tx, image, signature) {
log::debug!("recording signature for {}: {e}", image.0);
}
}
match tx.commit() {
Ok(()) => n,
Err(e) => {
log::warn!("committing signatures: {e}");
0
}
}
}
/// Every image's thumbnail-store key, in one query.
///
/// Read whole rather than asked per image: the table is one small row per
/// photograph, and a query per image would be tens of thousands of statements
/// to save a megabyte.
fn thumbnail_keys(conn: &rusqlite::Connection) -> HashMap<ImageId, u64> {
let mut out = HashMap::new();
let Ok(mut stmt) = conn.prepare("SELECT image_id, file_id FROM remote") else {
return out;
};
let Ok(rows) = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))) else {
return out;
};
for (image, file) in rows.flatten() {
out.insert(ImageId(image as u64), file as u64);
}
out
}
/// Fill in the burst badge on every cell of the loaded window.
///
/// One query for the window, in the same style as the collection badges and the
/// rating counts beside it — a grid that asks the catalog a question per cell is
/// a grid that stutters under a finger.
///
/// `ids` is the window in grid order, so the row index into the model is the
/// index into it.
pub fn sync_badges(window: &AppWindow, catalog: &Catalog, ids: &[ImageId]) {
if ids.is_empty() {
return;
}
let found = match bursts::memberships(catalog.connection(), ids) {
Ok(m) => m,
Err(e) => {
log::debug!("reading burst membership: {e}");
return;
}
};
let model = window.get_library_cells();
for (row, id) in ids.iter().enumerate() {
let (count, expanded) = match found.get(id) {
Some(m) => (m.size as i32, m.expanded),
// Not in a burst at all, which is most of a library.
None => (0, false),
};
if let Some(mut cell) = model.row_data(row) {
if cell.burst_count != count || cell.burst_expanded != expanded {
cell.burst_count = count;
cell.burst_expanded = expanded;
model.set_row_data(row, cell);
}
}
}
}
/// Open or close the burst one cell belongs to.
///
/// Which direction is decided from what the catalog says the group is currently
/// doing, rather than from the cell's own `burst-expanded`. The cell is a copy
/// of that state and can be one reload behind; the table cannot.
///
/// The caller reloads the grid rather than repainting it, because collapsing
/// changes what the grid's *query* returns: the row count, the scrollbar and
/// the ordinal a scrub resolves all move together, and they can only stay in
/// step by being read again together.
///
/// Returns whether anything changed, so a click on a cell that is in no burst —
/// an entirely normal thing to happen — costs no round trip through the grid.
pub fn toggle(catalog: &Catalog, image: ImageId) -> bool {
let conn = catalog.connection();
let Ok(found) = bursts::memberships(conn, &[image]) else {
return false;
};
let Some(membership) = found.get(&image) else {
return false;
};
match bursts::set_expanded(conn, membership.burst_id, !membership.expanded) {
Ok(()) => true,
Err(e) => {
log::warn!("toggling burst {}: {e}", membership.burst_id.0);
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A catalog with two frames of one burst, and a thumbnail store holding a
/// picture for each.
///
/// `name` keeps two tests from sharing a directory, since they run in
/// parallel. Same shape as the store tests in `library`, which is also why
/// this reaches for `temp_dir` rather than a crate: nothing else here needs
/// one.
fn library(name: &str) -> (Catalog, PathBuf) {
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
for (id, at) in [(1i64, 1000i64), (2, 1001)] {
c.execute(
"INSERT INTO images(id, root_id, source_ref, captured_at, camera, added_at)
VALUES (?1, 1, ?2, ?3, 'Canon EOS R5', 0)",
rusqlite::params![id, format!("IMG_{id}.CR3"), at],
)
.unwrap();
c.execute(
"INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)",
rusqlite::params![id, 100 + id],
)
.unwrap();
}
let dir = std::env::temp_dir().join(format!("dr-ui-bursts-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
{
let mut store = ThumbStore::open(&dir).unwrap();
for (id, shift) in [(1u64, 0usize), (2, 1)] {
let rgba = picture(shift);
let bytes = dr_thumbs::encode_rgba(64, 64, &rgba).unwrap();
store
.put(
100 + id,
ThumbSize::Grid,
&dr_thumbs::Thumbnail {
width: 64,
height: 64,
bytes,
},
)
.unwrap();
}
}
(cat, dir)
}
/// A blocky scene, moved sideways by `shift` pixels — one frame of a burst
/// and then the next.
fn picture(shift: usize) -> Vec<u8> {
let mut out = vec![0u8; 64 * 64 * 4];
for y in 0..64 {
for x in 0..64 {
let v = (((x + shift) / 8) * 37 + (y / 8) * 91) as u8;
let p = (y * 64 + x) * 4;
out[p] = v;
out[p + 1] = v;
out[p + 2] = v;
out[p + 3] = 255;
}
}
out
}
#[test]
fn the_pass_hashes_from_thumbnails_and_groups_what_it_hashed() {
let (cat, dir) = library("hashes");
let conn = cat.connection();
let store = ThumbStore::open(&dir).unwrap();
let outstanding = bursts::images_without_signature(conn).unwrap();
assert_eq!(outstanding.len(), 2);
let (tx, _rx) = std::sync::mpsc::channel();
let hashed = hash_all(conn, &store, &outstanding, &tx);
assert_eq!(hashed, 2, "both thumbnails should have yielded a signature");
let report = bursts::regroup(conn, Rules::default()).unwrap();
assert_eq!(report.bursts, 1, "the two frames were not grouped");
assert_eq!(report.frames, 2);
}
#[test]
fn an_image_with_no_thumbnail_keeps_its_null() {
let (cat, dir) = library("no-thumb");
let conn = cat.connection();
conn.execute(
"INSERT INTO images(id, root_id, source_ref, captured_at, added_at)
VALUES (9, 1, 'IMG_9.CR3', 1002, 0)",
[],
)
.unwrap();
let store = ThumbStore::open(&dir).unwrap();
let outstanding = bursts::images_without_signature(conn).unwrap();
let (tx, _rx) = std::sync::mpsc::channel();
assert_eq!(hash_all(conn, &store, &outstanding, &tx), 2);
// And it is still offered next time, rather than being written off.
assert_eq!(
bursts::images_without_signature(conn).unwrap(),
vec![ImageId(9)]
);
}
#[test]
fn toggling_a_cell_that_is_in_no_burst_changes_nothing() {
let (cat, _dir) = library("no-burst");
assert!(!toggle(&cat, ImageId(1)));
}
}
+151 -1
View File
@@ -14,7 +14,8 @@ use std::sync::Arc;
use dr_decode::RawImage;
use dr_gpu::{
AdjustPass, DemosaicedImage, Demosaicer, GpuContext, Histogram, HistogramPass, MaskPass,
AdjustPass, DemosaicedImage, Demosaicer, FocusPeakPass, FocusPeaking, GpuContext, Histogram,
HistogramPass, MaskPass,
};
use dr_pipeline::mask::{MaskLayer, MaskSource};
@@ -722,6 +723,25 @@ pub struct DevelopSession {
/// old driver, a device without the storage-buffer atomics it needs — the
/// photographer loses the histogram and keeps the photograph.
histogram: Option<HistogramPass>,
/// TRACES: FR-CULL-3
/// The focus-peaking overlay, on the same terms as the histogram above:
/// optional, because a device that cannot compile the pass is still a
/// device that can develop the photograph. What is lost is an instrument,
/// not the picture.
peak: Option<FocusPeakPass>,
/// TRACES: FR-CULL-3
/// What the photographer asked the overlay to look like, or `None` for
/// off.
///
/// **Interface state, not part of the edit** — the same category as
/// `show_overlay` beside it. It changes no pixel of the photograph, it is
/// not in the sidecar, and it is not on the undo stack: pressing undo
/// after switching peaking on should take back the last *edit*, not the
/// last thing looked at.
///
/// An `Option` rather than a bool plus a settings field, so that "off" and
/// "on, in some configuration" cannot disagree with each other.
peaking: Option<FocusPeaking>,
/// TRACES: FR-DEV-3
/// The region map local masks select from, once it has been computed.
@@ -889,6 +909,10 @@ impl DevelopSession {
histogram: HistogramPass::new(ctx)
.inspect_err(|e| log::warn!("no histogram on this device: {e}"))
.ok(),
peak: FocusPeakPass::new(ctx)
.inspect_err(|e| log::warn!("no focus peaking on this device: {e}"))
.ok(),
peaking: None,
segmentation: None,
masks: None,
subjects: None,
@@ -2903,6 +2927,105 @@ impl DevelopSession {
.ok()
}
/// TRACES: FR-CULL-3
/// Whether this device could build the focus-peaking overlay.
///
/// Asked by the interface so that it can say the overlay is unavailable
/// rather than offer a switch that does nothing. The same courtesy the
/// histogram is not paid, and should be: a control that silently does
/// nothing is worse than one that is visibly absent.
pub fn peaking_available(&self) -> bool {
self.peak.is_some()
}
/// TRACES: FR-CULL-3
/// What the overlay is set to, or `None` when it is off.
pub fn peaking(&self) -> Option<FocusPeaking> {
self.peaking
}
/// TRACES: FR-CULL-3
/// Switch the overlay on with these settings, or off.
///
/// Asking for peaking on a device that could not build the pass leaves it
/// off, so that [`Self::peaking`] never claims something is being drawn
/// that is not. Switching off drops the overlay textures rather than
/// merely stopping drawing them: a resident overlay from the last frame is
/// one interface bug away from being laid over the next photograph.
pub fn set_peaking(&mut self, settings: Option<FocusPeaking>) {
self.peaking = settings.filter(|_| self.peak.is_some());
if self.peaking.is_none() {
if let Some(pass) = self.peak.as_mut() {
pass.clear();
}
}
}
/// TRACES: FR-CULL-3 | NFR-P14
/// Mark the in-focus regions of the frame that is currently on the canvas.
///
/// **Reads the frame [`Self::render`] last produced**, exactly as
/// [`Self::histogram`] does and for the same reason: the overlay has to
/// describe what the photographer is looking at, and rendering a second
/// time to measure it would cost a pass and admit the possibility of the
/// two disagreeing about the picture.
///
/// That the frame is the displayed one is what makes the marks land where
/// the eye is. It is at viewport resolution, cropped and zoomed as the
/// view is, and — the point of FR-CULL-3 — descended from sensor data
/// through the demosaic rather than from the camera's embedded JPEG, whose
/// in-body sharpening this would otherwise be measuring at least as much
/// as the lens.
///
/// **Call this only after a settled render.** See
/// [`dr_gpu::FocusPeakPass::render`] for why a half-resolution draft frame
/// cannot be measured for sharpness.
///
/// `None` where nothing has been rendered, where peaking is off, or where
/// the device could not build the pass.
pub fn focus_overlay(&mut self) -> Option<slint::Image> {
let settings = self.peaking?;
// Cloned rather than borrowed: a `wgpu::Texture` handle is an `Arc`,
// and holding a shared borrow of `self.adjust` across the mutable
// borrow of `self.peak` would cost a `Self { .. }` destructure to say
// something the clone says in one word.
let frame = self.adjust.output()?.clone();
let pass = self.peak.as_mut()?;
let overlay = pass
.render(&frame, settings)
.inspect_err(|e| log::warn!("focus peaking failed: {e}"))
.ok()?
.clone();
#[cfg(not(target_os = "android"))]
{
// A layer over the canvas rather than a tint in it, so nothing
// here reaches the histogram or an export — see `FocusPeakPass`
// for the whole of that argument.
slint::Image::try_from(overlay)
.inspect_err(|e| log::warn!("the focus overlay is not importable: {e}"))
.ok()
}
// Android draws with Skia over OpenGL and cannot sample a
// `wgpu::Texture`, so the overlay follows the frame it belongs to back
// through memory (technical-debt.md TD-1). The measurement still
// happens on the GPU; only this last hop does not.
#[cfg(target_os = "android")]
{
let _ = overlay;
let (rgba, w, h) = pass
.read_overlay()
.inspect_err(|e| log::warn!("reading the focus overlay back: {e}"))
.ok()?;
let mut buf = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(w, h);
let wanted = (w as usize) * (h as usize) * 4;
let src = &rgba[..wanted.min(rgba.len())];
buf.make_mut_bytes()[..src.len()].copy_from_slice(src);
Some(slint::Image::from_rgba8(buf))
}
}
/// Render the *whole* frame for the crop overlay to be drawn over.
///
/// Crop mode cannot use [`Self::render`]: that applies the crop, so the
@@ -2943,6 +3066,33 @@ impl DevelopSession {
Ok((image, rw, rh))
}
/// TRACES: FR-PLAT-AND-5 | NFR-RES-1
/// Give back the GPU memory this session is holding only to be fast.
///
/// The edit is untouched: the graph and its history are CPU-side by
/// design (ARCH §6.1), so the photograph, the undo stack and the viewport
/// all survive and the next frame simply costs what the first one did.
///
/// # What is not released, and what it is waiting on
///
/// The demosaiced source is the largest single allocation a session holds
/// — a 24 MP frame is about 190 MB of `Rgba16Float` — and it is
/// deliberately kept. Dropping it would need the session to be able to
/// rebuild itself from the file, and rebuilding a session from a durable
/// record is FR-PLAT-AND-3, which is not built. Freeing it now would not
/// be an eviction; it would be closing the photograph without telling
/// anyone. Likewise the subject distance fields and the segmentation map:
/// each is guarded by a key recording what it was built from, and freeing
/// one without invalidating its key is the failure `AdjustPass` documents
/// under `colour_key`.
///
/// So this is the part of the GPU tier that can be given back and asked
/// for again with no other machinery, which is exactly as far as an
/// eviction should go.
pub fn release_gpu_caches(&mut self) {
self.adjust.release_caches();
}
/// The displayed size, for sizing the viewport.
///
/// The *framed* size, not the sensor's: cropping and quarter turns change
+15
View File
@@ -111,6 +111,21 @@ impl IdentityController {
fn clear_picks(&self) {
self.picked.borrow_mut().clear();
}
/// TRACES: FR-PLAT-AND-5 | NFR-RES-1
/// Drop the decoded rail portraits.
///
/// The one in-memory image cache in this crate that is unbounded by
/// anything but the library: one decoded portrait per person, kept for as
/// long as the person exists. On a library with a few hundred named people
/// that is worth tens of megabytes of nothing but a saved decode.
///
/// Costless to lose. `refresh` rebuilds any portrait it does not find, so
/// the only consequence is the JPEG decode this cache exists to skip, and
/// only for the people the rail is actually showing at the time.
pub fn clear_covers(&self) {
self.covers.borrow_mut().clear();
}
}
/// Push the people rail and the face grid into the window.
+190
View File
@@ -20,6 +20,7 @@
//! in `ui/` names an operation or knows a shader exists (FR-DEV-3a).
mod activity;
mod bursts;
mod collections_ui;
mod derived_sync;
mod develop;
@@ -38,7 +39,10 @@ mod library_ui;
#[cfg(live_style)]
mod live_style;
mod masks_ui;
pub mod memory;
mod net_runtime;
mod peaking;
mod preset_store;
mod presets;
mod remote;
mod segmentation;
@@ -316,6 +320,12 @@ fn reset_view_state(window: &AppWindow) {
// beside the next one's filename is a confident, precise lie, and the gap
// before the new frame settles is exactly long enough to read it.
window.set_histogram(histogram::empty());
// TRACES: FR-CULL-3
// The marks go down with it, and for the same reason. What is *not* reset
// is whether peaking is switched on: that is a way of looking at a folder
// rather than a property of one photograph, so it survives to the next
// frame — see `chosen_peaking` for the whole of that argument.
window.set_focus_overlay_ready(false);
// TRACES: FR-DEV-3
// The region map belongs to one photograph. Carrying the stack, the
// overlay or the crosshair to the next one would offer a selection of
@@ -1016,6 +1026,22 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// each save over the other.
let settings = settings_ui::SettingsController::new();
// TRACES: FR-PLAT-AND-5
// The thumbnail tier. Registered here, beside the thing it frees, so that
// a controller which grows another cache is one line from offering it up.
//
// Weak, not strong: `run` returns when the window closes, and a registry
// holding the last reference to a controller would keep it — and every
// decoded portrait in it — alive past the interface it belonged to.
{
let identity = std::rc::Rc::downgrade(&identity);
memory::evict_at(memory::Tier::Thumbnails, move || {
if let Some(ctl) = identity.upgrade() {
ctl.clear_covers();
}
});
}
// 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).
@@ -1429,6 +1455,32 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// The current develop session, if the file yielded sensor data.
let session: Rc<RefCell<Option<DevelopSession>>> = Rc::new(RefCell::new(None));
// TRACES: FR-PLAT-AND-5
// The GPU tier — the first thing given back under memory pressure, and on
// Android the only thing given back merely for going into the background.
//
// `try_borrow_mut` rather than `borrow_mut`, and the miss is not an error
// worth reporting. A memory warning can land in the middle of a render, at
// which point the slot is already borrowed and freeing its textures under
// the code drawing with them is not something to do politely — skipping is
// correct, because the pass that is running will have finished by the time
// the platform asks again, and a warning that has not been acted on is
// always followed by another one.
{
let session = Rc::downgrade(&session);
memory::evict_at(memory::Tier::Gpu, move || {
let Some(session) = session.upgrade() else {
return;
};
let Ok(mut slot) = session.try_borrow_mut() else {
return;
};
if let Some(open) = slot.as_mut() {
open.release_gpu_caches();
}
});
}
// TRACES: FR-DEV-6 | FR-CAT-8
// The settings clipboard, and where the open image's edit is stored.
//
@@ -1468,11 +1520,24 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// is redrawn, and those are very different rates.
let drawn_history: Rc<Cell<Option<u64>>> = Rc::new(Cell::new(None));
// TRACES: FR-CULL-3
// How the photographer wants focus peaking drawn, or `None` for off.
//
// **Held here rather than on the session, which is the opposite of where
// every edit lives.** A session is one photograph; peaking is a way of
// *looking* at a folder of them. Someone culling three thousand frames
// switches it on once, and a flag that reset with the session would ask
// them to switch it on three thousand times — which is why
// `reset_view_state` deliberately leaves it alone while emptying the
// histogram beside it.
let chosen_peaking: Rc<Cell<Option<dr_gpu::FocusPeaking>>> = Rc::new(Cell::new(None));
let render_now: Render = {
let session = session.clone();
let viewport = viewport.clone();
let drawn_history = drawn_history.clone();
let display = display.clone();
let chosen_peaking = chosen_peaking.clone();
Rc::new(move |window: &AppWindow, draft: bool| {
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return };
@@ -1533,6 +1598,16 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// arrival takes.
spots_ui::sync_panel(window, s);
// TRACES: FR-CULL-3
// The session owns the pass and the interface owns the choice, so
// they are joined here — on the one path every frame takes, which
// is also what makes a photograph opened with peaking already on
// arrive with its marks rather than without them.
if s.peaking() != chosen_peaking.get() {
s.set_peaking(chosen_peaking.get());
}
window.set_peaking_available(s.peaking_available());
let (mut w, mut h) = *viewport.borrow();
// **Half resolution while the gesture is still moving.**
@@ -1595,6 +1670,34 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
.map_or_else(histogram::empty, histogram::view),
);
}
// TRACES: FR-CULL-3 | NFR-P14
// **Marked on the settled frame and no other**, and unlike
// the histogram beside it the marks are taken *down* in
// between rather than left standing.
//
// The reason is not budget — the dispatch is a fraction of
// a millisecond and would fit inside a draft frame
// comfortably. It is that peaking measures the top octave
// of the frame it is given, and a draft frame is rendered
// at half resolution: a defocused edge that spans four
// pixels there spans two, which is the signature of a
// sharp one. Measuring it would mark the out-of-focus
// background of every photograph, briefly, during every
// drag. A stale overlay is no better, because a pan moves
// the picture out from under it.
//
// So the marks pause while a control is moving and return
// when it stops, which the panel says out loud rather than
// leaving to be discovered.
let overlay = (!draft).then(|| s.focus_overlay()).flatten();
match overlay {
Some(image) => {
window.set_focus_overlay(image);
window.set_focus_overlay_ready(true);
}
None => window.set_focus_overlay_ready(false),
}
}
Err(e) => {
log::warn!("render failed: {e}");
@@ -1602,6 +1705,11 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// No frame, so nothing to describe. The stale plot would
// otherwise sit beside the error message looking current.
window.set_histogram(histogram::empty());
// TRACES: FR-CULL-3
// And nothing to mark. Focus marks over the last frame
// that rendered, beside a message saying this one did not,
// is the same confident lie in a second instrument.
window.set_focus_overlay_ready(false);
}
}
})
@@ -2025,6 +2133,24 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
collections.clone(),
);
// TRACES: FR-DEV-6
// The saved half of the same requirement, wired from the same bundle:
// applying a named preset to the open image is the paste path with a
// different source.
presets::wire_named(
&window,
presets::NamedPresets::open(),
presets::Develop {
session: session.clone(),
rows: rows.clone(),
redraw: redraw.clone(),
open: open_image.clone(),
},
settings.clone(),
library.clone(),
collections.clone(),
);
// Close the knot left open beside `open_from_library`: the grid's
// "‹ Library" button was wired before there was a session to save.
let weak = window.as_weak();
@@ -2822,6 +2948,70 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
});
}
// TRACES: FR-CULL-3
// The peaking switch and its two choices.
//
// All three write `chosen_peaking` and then redraw, because the marks are
// produced by a compute pass over the rendered frame: there is nothing the
// interface can change about the overlay that does not require the frame
// to be measured again. Turning peaking *off* redraws for the same reason
// — that render is what drops the overlay textures and clears the flag.
{
let weak = window.as_weak();
let chosen = chosen_peaking.clone();
let redraw = redraw.clone();
window.on_peaking_toggled(move |on| {
let Some(w) = weak.upgrade() else { return };
// Built from the chips as they currently stand rather than from a
// remembered value: they are what the photographer can see, and an
// overlay that came back in a configuration the panel is not
// showing would be the panel lying about itself.
let next = on.then(|| dr_gpu::FocusPeaking {
sensitivity: peaking::sensitivity(w.get_peaking_sensitivity()),
colour: peaking::colour(w.get_peaking_colour()),
});
chosen.set(next);
w.set_peaking_on(next.is_some());
redraw(&w);
});
}
{
let weak = window.as_weak();
let chosen = chosen_peaking.clone();
let redraw = redraw.clone();
window.on_peaking_sensitivity_picked(move |index| {
let Some(w) = weak.upgrade() else { return };
w.set_peaking_sensitivity(index);
// Only reachable while peaking is on — the chips are not drawn
// otherwise — but written as a conditional rather than an
// `expect`, because a panel is free to change its mind about that
// and nothing here should fall over when it does.
if let Some(mut current) = chosen.get() {
current.sensitivity = peaking::sensitivity(index);
chosen.set(Some(current));
redraw(&w);
}
});
}
{
let weak = window.as_weak();
let chosen = chosen_peaking.clone();
let redraw = redraw.clone();
window.on_peaking_colour_picked(move |index| {
let Some(w) = weak.upgrade() else { return };
w.set_peaking_colour(index);
if let Some(mut current) = chosen.get() {
current.colour = peaking::colour(index);
chosen.set(Some(current));
redraw(&w);
}
});
}
// The chips open on whatever the vocabulary calls its default, so the
// panel and the pass agree before anything has been pressed.
window.set_peaking_sensitivity(peaking::sensitivity_index(Default::default()));
window.set_peaking_colour(peaking::colour_index(Default::default()));
// TRACES: FR-DSP-8 | FR-DSP-6
// And which display that canvas is on, from now until the window closes.
display_ui::attach(&window, &display, &viewport, redraw.clone());
+192 -11
View File
@@ -80,7 +80,20 @@ pub enum ScanMessage {
/// amount of string matching on the far side can reliably recover it.
/// Without the flag a dead connection and a bad password produce the same
/// banner, which sends the user to re-enter a credential that was fine.
Failed { message: String, offline: bool },
///
/// `lost_root` is the same idea one step further out, and it is carried
/// separately from `offline` rather than folded into it because the two
/// end differently. An offline library comes back when the network does,
/// with nothing asked of anyone; a library whose root cannot be opened
/// comes back only when someone restores access to it — a share put back
/// on the server, a drive plugged in, and in time a document tree granted
/// again once one can be (FR-PLAT-AND-2). Both show the same grid of what
/// is stored locally, and they must not offer the same explanation.
Failed {
message: String,
offline: bool,
lost_root: bool,
},
}
/// One decoded thumbnail, ready for the grid.
@@ -186,6 +199,26 @@ const VISIBLE_UNALIASED: &str = "shadowed_by IS NULL AND trashed_at IS NULL";
/// restore the same frame twice.
const TRASHED: &str = "i.shadowed_by IS NULL AND i.trashed_at IS NOT NULL";
/// TRACES: FR-CULL-5
/// The clause that hides the frames a collapsed burst is standing in for.
///
/// Subject to exactly the discipline [`VISIBLE`] is under, and for the same
/// reason: the header's count, the scrollbar's size, the run a shift-click
/// resolves and the ordinal a scrub lands on are four answers about one list.
/// A burst folded away in the cells but still counted in the total would leave
/// the grid ending in rows that draw nothing, with no clue why.
///
/// The predicate itself is `dr_catalog::bursts`'s, not this file's, so the
/// interface and the pass that writes the table cannot come to disagree about
/// what collapsed means.
///
/// A function rather than a constant because it has to name the image table,
/// and the grid aliases it as `i` where the timeline's queries do not. `image`
/// is a table name from this file and never anything a user supplied.
fn uncollapsed(image: &str) -> String {
format!(" AND {}", dr_catalog::bursts::not_collapsed_away(image))
}
/// TRACES: FR-CAT-4
/// The order the grid lists photographs in: when they were taken.
///
@@ -1146,6 +1179,7 @@ pub fn spawn_scan(
let _ = tx.send(ScanMessage::Failed {
message: e.message,
offline: e.offline,
lost_root: e.lost_root,
});
}
});
@@ -1160,6 +1194,7 @@ pub fn spawn_scan(
struct ScanFailure {
message: String,
offline: bool,
lost_root: bool,
}
impl ScanFailure {
@@ -1169,6 +1204,7 @@ impl ScanFailure {
Self {
message: message.to_string(),
offline: false,
lost_root: false,
}
}
}
@@ -1177,11 +1213,48 @@ impl From<dr_sync::RemoteError> for ScanFailure {
fn from(e: dr_sync::RemoteError) -> Self {
Self {
offline: e.indicates_offline(),
lost_root: e.indicates_lost_root(),
message: e.to_string(),
}
}
}
/// TRACES: FR-PLAT-AND-2 | FR-CAT-9
/// Record that a library can no longer be opened, without losing it.
///
/// Called on the worker, before the failure crosses the channel, because this
/// is where the catalog handle is — and because the marking must be durable
/// whether or not anyone is left to draw a banner. A process killed between
/// the failure and the next launch must still come back knowing what it could
/// not reach.
///
/// Nothing is deleted. Every rating, every edit and every row stays exactly
/// where it was; what changes is that the images now say they are offline, so
/// the grid can show them as held-not-here rather than as ordinary
/// photographs whose thumbnails happen to be failing one at a time.
///
/// A root with no row yet is the first scan of a library that has never
/// succeeded, and there is nothing to mark — the failure alone is the whole
/// story, and the launch screen is where it is told.
fn mark_library_offline(catalog: &Catalog, root: &str) {
let conn = catalog.connection();
let root_id: Option<i64> = conn
.query_row(
"SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'",
[root],
|r| r.get(0),
)
.ok();
let Some(root_id) = root_id else {
log::info!("library {root} has no catalog root yet; nothing to mark offline");
return;
};
match dr_catalog::mark_root_offline(conn, dr_types::RootId(root_id as u64)) {
Ok(()) => log::warn!("library {root} is unreachable; its images are marked offline"),
Err(e) => log::error!("could not mark {root} offline: {e}"),
}
}
fn run_scan(
tx: &Sender<ScanMessage>,
conn: Connection,
@@ -1199,21 +1272,50 @@ fn run_scan(
let rt = crate::net_runtime::build().map_err(ScanFailure::local)?;
rt.block_on(async {
let backend = crate::remote::connect(&conn).map_err(ScanFailure::local)?;
// TRACES: FR-PLAT-AND-2 | FR-CAT-9
// Classified rather than flattened to a local failure, because the
// removed-card case never gets as far as a request: the folder
// connector checks its root when it is constructed, so a library on an
// ejected card fails here and not in the walk. Reported as an ordinary
// error it left the grid showing a healthy library of images that
// could no longer be opened, one silent thumbnail failure at a time.
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
if e.indicates_lost_root() {
mark_library_offline(&catalog, &root);
}
return Err(e.into());
}
};
// Stored folder ETags, so an unchanged subtree is skipped whole. On a
// first run this is empty and the walk is complete; on every run after
// 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 scanned = 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?;
.await;
// TRACES: FR-PLAT-AND-2 | FR-CAT-9
// Written before the failure is reported, not after: the banner is a
// consequence of the catalog state and not the other way round, and a
// process that dies between the two must come back knowing.
let result = match scanned {
Ok(r) => r,
Err(e) => {
if e.indicates_lost_root() {
mark_library_offline(&catalog, &root);
}
return Err(e.into());
}
};
persist(&catalog, &root, &result).map_err(ScanFailure::local)?;
@@ -1306,13 +1408,27 @@ fn persist(
.ok()
});
// TRACES: FR-PLAT-AND-2 | FR-CAT-9
// The `availability` arm is what ends an offline library, and it does
// it one photograph at a time. 3 is `Availability::Offline` and 0 is
// `MetadataOnly`, the same code this statement inserts new rows with —
// so a row that was marked offline when the root became unreachable is
// returned to exactly the state a fresh scan would have given it, and
// a row that was never marked is not touched at all.
//
// Conditional rather than a blanket reset for the same reason
// `dr_catalog::walk` restores per file rather than per root: the only
// thing that may clear "I could not reach this" is having reached it,
// and this statement runs precisely once per file the scan listed.
tx.execute(
"INSERT INTO images(root_id, folder_id, source_ref, format, file_size,
availability, metadata_state, added_at)
VALUES (?1, ?2, ?3, ?4, ?5, 0, 1, ?6)
ON CONFLICT(root_id, source_ref) DO UPDATE SET
file_size = excluded.file_size,
folder_id = excluded.folder_id",
folder_id = excluded.folder_id,
availability = CASE WHEN images.availability = 3
THEN 0 ELSE images.availability END",
rusqlite::params![
root_id,
folder_id,
@@ -3816,10 +3932,11 @@ pub fn read_cells_scoped(
.join(",");
let rated = filter.sql();
let (order, order_params) = grid_order_for(catalog, Some(scope));
let folded = uncollapsed("i");
let sql = format!(
"SELECT {CELL_COLUMNS}
FROM images i
WHERE {VISIBLE}{rated}
WHERE {VISIBLE}{rated}{folded}
AND i.id IN (SELECT image_id FROM collection_members
WHERE collection_id IN ({placeholders}))
{order}
@@ -3854,11 +3971,12 @@ fn read_cells_all(
limit: usize,
) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> {
let rated = filter.sql();
let folded = uncollapsed("i");
let mut rows = {
let mut stmt = catalog.connection().prepare(&format!(
"SELECT {CELL_COLUMNS}
FROM images i
WHERE {VISIBLE}{rated}
WHERE {VISIBLE}{rated}{folded}
{GRID_ORDER}
LIMIT ?1 OFFSET ?2"
))?;
@@ -3964,10 +4082,15 @@ pub fn read_ids_span(
// different ORDER BY names a different photograph.
let (order, order_params) = grid_order_for(catalog, scope);
params.extend(order_params);
// And the same folding, for the same reason one step further on: a
// collapsed burst is one cell in the grid, so an ordinal counted over
// a list that still held every frame of it would name a photograph
// several places away from the one the user pointed at.
let folded = uncollapsed("i");
(
format!(
"SELECT i.id FROM images i
WHERE {VISIBLE}{rated}{clause}
WHERE {VISIBLE}{rated}{folded}{clause}
{order}
LIMIT ? OFFSET ?"
),
@@ -4096,9 +4219,10 @@ pub fn total_images_scoped(
// Counted through `images` rather than over `collection_members` alone, so
// `VISIBLE` applies — a trashed photograph is still a member row, and
// counting it made the header claim images the grid would not draw.
let folded = uncollapsed("i");
let sql = format!(
"SELECT count(DISTINCT i.id) FROM images i
WHERE {VISIBLE}{rated}
WHERE {VISIBLE}{rated}{folded}
AND i.id IN (SELECT image_id FROM collection_members
WHERE collection_id IN ({placeholders}))"
);
@@ -4409,8 +4533,9 @@ fn total_images_filtered(
filter: &RatingFilter,
) -> Result<usize, dr_catalog::CatalogError> {
let rated = filter.sql();
let folded = uncollapsed("i");
let n: i64 = catalog.connection().query_row(
&format!("SELECT count(*) FROM images i WHERE {VISIBLE}{rated}"),
&format!("SELECT count(*) FROM images i WHERE {VISIBLE}{rated}{folded}"),
[],
|r| r.get(0),
)?;
@@ -4956,12 +5081,16 @@ mod tests {
#[test]
fn the_window_read_walks_the_ordering_index() {
let catalog = with_images(20);
// Including the burst clause, because the grid includes it: a
// predicate that quietly cost the ordering index would put the sort
// back and this is the only place that would notice.
let folded = uncollapsed("i");
let plan: Vec<String> = catalog
.connection()
.prepare(&format!(
"EXPLAIN QUERY PLAN
SELECT {CELL_COLUMNS} FROM images i
WHERE {VISIBLE}
WHERE {VISIBLE}{folded}
{GRID_ORDER}
LIMIT 10 OFFSET 5"
))
@@ -5014,6 +5143,58 @@ mod tests {
catalog
}
/// TRACES: FR-CULL-5
/// A folded burst takes rows out of the cells, the count and the range a
/// shift-click resolves — all three, together.
///
/// This is the test that would fail if the clause were added to four of
/// the five queries that need it. That failure has no other symptom: the
/// header claims images the grid will not draw, the scrollbar sizes itself
/// for rows that are not there, and neither number looks wrong on its own.
#[test]
fn folding_a_burst_takes_the_same_rows_out_of_every_answer() {
use dr_catalog::bursts::{self, Rules, Signature};
let catalog = with_images(4);
// Three of the four are one burst: a second apart, one signature.
let ids = image_ids(&catalog);
for (n, id) in ids.iter().enumerate() {
let hash = if n < 3 { 0xFF00 } else { 0x00FF };
catalog
.connection()
.execute(
"UPDATE images SET captured_at = ?2, camera = 'Canon EOS R5',
perceptual_hash = ?3
WHERE id = ?1",
rusqlite::params![id.0 as i64, 1_000 + n as i64, Signature(hash).to_stored()],
)
.unwrap();
}
bursts::regroup(catalog.connection(), Rules::default()).unwrap();
let filter = RatingFilter::default();
// Open, as a new burst is: nothing has been taken away yet.
assert_eq!(read_cells(&catalog, 0, 50).unwrap().len(), 4);
assert_eq!(total_images_filtered(&catalog, &filter).unwrap(), 4);
bursts::set_expanded(catalog.connection(), ids[0], false).unwrap();
let cells = read_cells(&catalog, 0, 50).unwrap();
assert_eq!(cells.len(), 2, "the folded frames are still in the cells");
assert_eq!(
total_images_filtered(&catalog, &filter).unwrap(),
cells.len(),
"the header's count and the cells disagree"
);
assert_eq!(
read_ids_span(&catalog, None, &filter, false, 0, 49)
.unwrap()
.len(),
cells.len(),
"a shift-click over the whole grid would select frames it cannot show"
);
}
fn image_ids(catalog: &Catalog) -> Vec<dr_types::ImageId> {
let mut stmt = catalog
.connection()
+164 -20
View File
@@ -270,6 +270,22 @@ pub struct LibraryController {
/// judgement, and carrying the old one over would report a server down
/// that was never contacted.
reachability: RefCell<dr_sync::Reachability>,
/// TRACES: FR-PLAT-AND-2 | FR-CAT-9
/// Why the library folder itself could not be opened, if it could not.
///
/// Beside [`Self::reachability`] rather than inside it, because
/// `dr_sync::Reachability` models *the server*, and it is deliberately
/// unmoved by a refusal — a forbidden file must not report the network as
/// down (see its own tests). A revoked tree grant is a refusal, so folding
/// it in would either break that rule or need an exception carved through
/// it.
///
/// Set only by a scan that failed at the root, and cleared only by one
/// that succeeded. Both states drive the same banner as being offline
/// does, because what the user can do is the same — carry on with what is
/// stored on the device — but the sentence under it is different, and so
/// is what will end it.
root_lost: RefCell<Option<String>>,
/// TRACES: FR-NC-6a
/// Drains the pin downloader. Held so a second pin replaces the timer
/// rather than leaving two draining the same finished channel.
@@ -372,6 +388,7 @@ impl LibraryController {
sidecar_timer: RefCell::new(None),
generation: std::cell::Cell::new(0),
reachability: RefCell::new(dr_sync::Reachability::new()),
root_lost: RefCell::new(None),
outbox_timer: RefCell::new(None),
outbox_maybe_dirty: std::cell::Cell::new(true),
geometry_timer: RefCell::new(None),
@@ -443,10 +460,15 @@ impl LibraryController {
}
}
/// TRACES: FR-CAT-9
/// Whether the app currently believes the server is unreachable.
/// TRACES: FR-CAT-9 | FR-PLAT-AND-2
/// Whether the library cannot be reached, for either of the two reasons.
///
/// One answer rather than two because every caller asks it for the same
/// purpose: to decide whether starting a transfer is worth attempting.
/// A revoked grant fails that question exactly as a dead network does, and
/// a sync started against it would spend its retries proving it.
pub fn is_offline(&self) -> bool {
self.reachability.borrow().is_offline()
self.reachability.borrow().is_offline() || self.root_lost.borrow().is_some()
}
/// Whether the grid is narrowed to locally-stored originals.
@@ -941,6 +963,17 @@ fn drain_scan(
{
log::info!("back online");
}
// TRACES: FR-PLAT-AND-2
// And it is the only evidence that clears a lost root,
// for the same reason: the walk began by listing the
// root, so a scan that finished is a root that opened.
// The rows it marked offline are restored one at a
// time by `library::persist`, as each file is listed
// again — this only stops the banner claiming what is
// no longer true.
if ctl.root_lost.borrow_mut().take().is_some() {
log::info!("library folder is readable again");
}
refresh_offline(&w, ctl);
// An incremental rescan lists almost nothing, so
@@ -995,7 +1028,11 @@ fn drain_scan(
stop(&ctl.scan_timer);
return;
}
ScanMessage::Failed { message, offline } => {
ScanMessage::Failed {
message,
offline,
lost_root,
} => {
log::warn!("scan failed: {message}");
w.set_library_scanning(false);
// Recorded as a failure even where it is only the
@@ -1004,7 +1041,26 @@ fn drain_scan(
// stopped because of it.
job.fail(message.clone());
if offline {
if lost_root {
// TRACES: FR-PLAT-AND-2 | FR-CAT-9
// The library folder itself could not be opened —
// a share withdrawn, an unplugged drive, and in
// time a revoked document-tree grant. The worker
// has already marked every row under this root
// offline and deleted none of them; this is the
// half the user sees.
//
// Tested first because it is also true that the
// library is unreachable, and the generic answer
// would be reached first and be less useful.
*ctl.root_lost.borrow_mut() = Some(message);
refresh_offline(&w, ctl);
// Same reason as the offline arm below: without
// this a launch that began with a revoked grant
// shows an empty grid, which is the one impression
// this whole path exists to avoid.
open_catalog_for_offline(&w, ctl, &catalog_path, &coll_ctl);
} else if offline {
// Not an error state. The catalog from the last
// successful scan is still on disk and still
// accurate for everything already indexed, so the
@@ -1585,17 +1641,35 @@ fn scope_is_pinned(catalog: &Catalog, images: &[dr_types::ImageId]) -> bool {
/// which is what keeps it testable without a display server.
fn refresh_offline(window: &AppWindow, ctl: &Rc<LibraryController>) {
let reach = ctl.reachability.borrow();
let offline = reach.is_offline();
// TRACES: FR-PLAT-AND-2
// A lost root wins over a dead network, and does so even when both are
// true — which is the ordinary case, since the scan that discovered the
// grant was gone was also the last request the app made. Reported the
// other way round the user is told to wait for a connection that is
// working, and the thing that would actually fix it is never mentioned.
let lost = ctl.root_lost.borrow();
let offline = reach.is_offline() || lost.is_some();
window.set_library_offline(offline);
window.set_library_offline_reason(reach.reason().unwrap_or_default().into());
window.set_library_offline_reason(match lost.as_deref() {
Some(why) => why.into(),
None => reach.reason().unwrap_or_default().into(),
});
window.set_library_offline_since(
reach
.offline_for(std::time::Instant::now())
.map(describe_duration)
.unwrap_or_default()
.into(),
// A duration is what a network outage has and a revoked permission
// does not: "for 4 minutes" invites waiting, and waiting is precisely
// what will not help here.
if lost.is_some() {
slint::SharedString::default()
} else {
reach
.offline_for(std::time::Instant::now())
.map(describe_duration)
.unwrap_or_default()
.into()
},
);
drop(lost);
// A stale scan error under an offline banner reports one problem twice.
if offline {
@@ -2214,6 +2288,11 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
// window, not one per cell.
rating: 0,
flag: 0,
// And by `bursts::sync_badges`, in one more query for the
// window. Zero is "not in a burst", which is what almost every
// photograph in a library is.
burst_count: 0,
burst_expanded: false,
}
})
.collect();
@@ -2246,6 +2325,9 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
.collect();
crate::collections_ui::sync_badges(window, catalog, &ids);
sync_ratings(window, catalog, &ids);
// How many frames each cell stands for, where it stands for several
// (FR-CULL-5).
crate::bursts::sync_badges(window, catalog, &ids);
// The rebuilt cells all carry `selected: false`, but the selection itself
// is a set of image ids and survives untouched. Without this the ticks
// vanished on every scroll — the selection was still there and still acted
@@ -3769,6 +3851,28 @@ fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
if !offline {
start_derived_sync(&w, &ctl_cb);
}
// And now every photograph in the library has a
// thumbnail, which is the only moment all of its burst
// signatures can be computed. See `bursts::start_pass`,
// which owns the pass and everything it needs to drain
// itself; what it wants from here is the paths and a
// way to say the grid has something new to draw.
if let Some((conn, _)) = ctl_cb.session.borrow().clone() {
let weak_after = w.as_weak();
let ctl_after = ctl_cb.clone();
crate::bursts::start_pass(
library::catalog_path(&conn.account),
library::thumbs_dir(&conn.account),
move |bursts| {
let Some(w) = weak_after.upgrade() else {
return;
};
if bursts > 0 && w.get_show_library() {
schedule_reload(&w, &ctl_after);
}
},
);
}
return;
}
}
@@ -4119,10 +4223,14 @@ fn capture_time_from_catalog(catalog: &Catalog, ordinal: usize) -> Option<i64> {
catalog
.connection()
.query_row(
"SELECT captured_at FROM images
WHERE shadowed_by IS NULL AND captured_at IS NOT NULL
ORDER BY captured_at
LIMIT 1 OFFSET ?1",
&format!(
"SELECT captured_at FROM images
WHERE shadowed_by IS NULL AND captured_at IS NOT NULL
AND {}
ORDER BY captured_at
LIMIT 1 OFFSET ?1",
dr_catalog::bursts::not_collapsed_away("images")
),
[ordinal as i64],
|r| r.get::<_, i64>(0),
)
@@ -4153,13 +4261,21 @@ fn scrub_to(window: &AppWindow, ctl: &Rc<LibraryController>, when: i64) {
// BY), so they never precede a dated one and the predicate below stays
// a simple `<`. Shadowed rows are excluded here exactly as the grid
// excludes them.
//
// A burst folded up occupies one row of the grid, so it must occupy one
// row of this count as well: an ordinal taken over the unfolded library
// would overshoot by every frame hidden earlier in it.
catalog
.connection()
.query_row(
"SELECT count(*) FROM images
WHERE shadowed_by IS NULL
AND captured_at IS NOT NULL
AND captured_at < ?1",
&format!(
"SELECT count(*) FROM images
WHERE shadowed_by IS NULL
AND captured_at IS NOT NULL
AND captured_at < ?1
AND {}",
dr_catalog::bursts::not_collapsed_away("images")
),
[when],
|r| r.get::<_, i64>(0),
)
@@ -4953,6 +5069,34 @@ pub fn wire<F>(
});
}
// Fold a burst up, or open it out (FR-CULL-5). A reload rather than a repaint, because
// it changes what the grid's query returns — see [`crate::bursts::toggle`].
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_burst_toggled(move |row| {
let Some(w) = weak.upgrade() else { return };
let id = ctl
.image_ids
.borrow()
.get(row as usize)
.map(|id| dr_types::ImageId(*id as u64));
let Some(id) = id else { return };
let changed = {
let borrow = ctl.catalog.borrow();
match borrow.as_ref() {
Some(catalog) => crate::bursts::toggle(catalog, id),
None => false,
}
};
if changed {
ctl.requested.borrow_mut().clear();
load_window(&w, &ctl);
}
});
}
// A rating or flag key. Applies to the whole selection, which is what
// makes judging a run of frames one keystroke rather than forty.
{
+276
View File
@@ -0,0 +1,276 @@
//! TRACES: FR-PLAT-AND-5 | NFR-RES-1 | FR-NC-6b
//! Giving memory back when the platform asks for it.
//!
//! Android kills the process that will not shrink. It does not negotiate and
//! it does not warn twice, and the app it kills is the one holding the most —
//! which, on a photo editor, is always this one. So the question this module
//! answers is not "how much can be freed" but "in what order", because the
//! caches differ enormously in what losing them costs.
//!
//! # The order, and why it is that order
//!
//! FR-PLAT-AND-5 states it: GPU tiles first, then proxies, then thumbnails.
//! Read as a rule rather than a list, it is *cheapest to rebuild goes first* —
//! a GPU allocation is remade from data already in memory, a proxy is remade
//! from a file already on disk, and a thumbnail may cost a network fetch.
//! [`Tier`] is that order written down where the code can be held to it, so
//! adding a cache means choosing its tier rather than choosing its position in
//! a hand-maintained sequence.
//!
//! What each tier actually reaches in this build is documented on the variant,
//! including where it reaches nothing yet. An empty tier is worth keeping
//! visible: it says the order is complete and the coverage is not.
//!
//! # Why this is a registry rather than a function that frees things
//!
//! Every cache worth evicting lives behind an `Rc<RefCell<…>>` owned by a
//! local in [`crate::run`], which is a two-thousand-line function whose
//! callbacks each hold their own handle. There is no central object to reach
//! them through, and inventing one to serve eviction alone would be a large
//! change to how the interface is wired for a small change in what it does.
//!
//! So `run` hands this module a closure per cache as it builds each one, and
//! this module owns only the ordering. The registration is next to the thing
//! being registered, which is also the property that keeps it honest: a cache
//! added later is one line away from being evictable, and a cache removed
//! takes its sink with it.
//!
//! # Everything here is single-threaded, and that is not a limitation
//!
//! The registry is a `thread_local`, holding `Fn()` rather than `Fn() + Send`,
//! because the pressure signal already arrives on the thread that owns the
//! caches. Slint's Android backend calls the event listener from inside
//! `poll_events`, which runs on the same thread as the event loop, which is
//! the thread `run` built everything on. Marshalling through
//! `invoke_from_event_loop` would add a hop and a lifetime question to solve a
//! problem that does not exist — and would arrive *after* the moment the
//! system asked, which for a memory warning is the one thing that matters.
//!
//! Anything reached from a worker thread — the thumbnail store, the original
//! cache — is on disk and bounded by its own budget (NFR-RES-4), and is not
//! what a memory warning is about.
use std::cell::RefCell;
/// How hard the platform is asking.
///
/// Two levels rather than Android's eight, because two is what the platform
/// actually delivers to this app. `ComponentCallbacks2.onTrimMemory` and its
/// `TRIM_MEMORY_*` grades are a Java callback on an `Activity` or
/// `Application`; a `NativeActivity` receives only `ANativeActivityCallbacks`,
/// whose memory callback is the ungraded `onLowMemory` — which is what
/// android-activity surfaces as `MainEvent::LowMemory`. Modelling grades the
/// entry point cannot observe would be modelling a wish.
///
/// [`Self::UiHidden`] recovers the one distinction that *is* observable and is
/// worth acting on, because it is the cheapest moment to give memory back:
/// nothing is on screen, so nothing that is freed has to be drawn again before
/// the user notices. It corresponds to `TRIM_MEMORY_UI_HIDDEN` in intent and
/// is derived from the activity being stopped rather than from a memory
/// warning at all.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Level {
/// The app is no longer on screen. Free what only a visible window needs.
UiHidden,
/// The system says it is short of memory. Free everything that can be
/// rebuilt.
Critical,
}
/// What a cache costs to lose, as an order.
///
/// Declared in eviction order and iterated in declaration order by
/// [`Tier::ORDER`], so the sequence FR-PLAT-AND-5 specifies is a property of
/// this type rather than of each call site.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tier {
/// GPU allocations that are rebuilt from data the process still holds.
///
/// The develop session's compiled pipelines, its detail intermediates and
/// its output textures. Rebuilt by the next render from the demosaiced
/// source, which is still resident — see
/// [`DevelopSession::release_gpu_caches`](crate::DevelopSession::release_gpu_caches)
/// for what is deliberately kept and what that is waiting on.
///
/// First because it is both the largest evictable pool on a mobile GPU and
/// the cheapest to refill: no I/O, no network, one frame's work.
Gpu,
/// Decoded image data rebuilt by reading a file again.
///
/// **Nothing registers here in this build, and the tier is kept anyway.**
/// There is no in-memory proxy cache: the only decoded full-size frame in
/// the process is the open develop session's, which belongs to
/// [`Tier::Gpu`] and cannot be dropped until a session can be rebuilt from
/// a durable record (FR-PLAT-AND-3). The on-disk original cache is a
/// different thing wearing the same word — freeing disk relieves no memory
/// pressure, and it already has a budget and an LRU of its own
/// (`dr_catalog::Cache`, NFR-RES-4).
Proxies,
/// Decoded thumbnails, rebuilt by decoding a stored JPEG again — or, at
/// worst, by fetching one.
///
/// Last because this is the tier a user sees losing: an evicted portrait
/// is a rail that redraws, and an evicted grid cell is a photograph that
/// greys out and comes back.
Thumbnails,
}
impl Tier {
/// The eviction order, in one place.
pub const ORDER: [Tier; 3] = [Tier::Gpu, Tier::Proxies, Tier::Thumbnails];
/// Whether this tier is given up at this level of pressure.
///
/// Hiding the window frees the GPU tier and nothing else. That is not
/// caution about the rest — it is that a backgrounded app has no window to
/// draw and therefore no use at all for a render pipeline, while its
/// thumbnails are exactly what the user will be looking at half a second
/// after they come back. Under [`Level::Critical`] the process is being
/// measured against being killed, and a slow return beats no return.
fn evicted_at(self, level: Level) -> bool {
match level {
Level::UiHidden => matches!(self, Tier::Gpu),
Level::Critical => true,
}
}
}
/// A cache that has offered itself up, and the tier it goes in.
///
/// Named because the registry is a `Vec` of these and the nested type is hard
/// to read at the use site rather than because either half means anything on
/// its own.
type Sink = (Tier, Box<dyn Fn()>);
thread_local! {
/// Registered sinks, in the order they were registered within a tier.
///
/// Within a tier the order is registration order and nothing depends on
/// it; between tiers it is [`Tier::ORDER`], which everything depends on.
static SINKS: RefCell<Vec<Sink>> = const { RefCell::new(Vec::new()) };
}
/// Offer a cache up for eviction at `tier`.
///
/// Called as each cache is built, so that the registration reads next to the
/// thing it is about. The closure is kept for the life of the thread; it must
/// therefore hold weak or shared handles rather than borrow anything, which is
/// the natural shape here because everything it can reach is already an `Rc`.
pub(crate) fn evict_at(tier: Tier, sink: impl Fn() + 'static) {
SINKS.with_borrow_mut(|sinks| sinks.push((tier, Box::new(sink))));
}
/// TRACES: FR-PLAT-AND-5
/// Give memory back, in [`Tier::ORDER`], as far down as `level` calls for.
///
/// Safe to call when nothing is registered — before the window is built, or on
/// a platform that never asks — in which case it does nothing at all.
///
/// The registry is taken out of the cell for the duration rather than borrowed
/// across the calls. A sink runs arbitrary interface code, and interface code
/// that registered another cache, or called this again, would otherwise meet a
/// `RefCell` it had already borrowed and abort the process. Freeing memory is
/// the wrong moment to be brittle about re-entry.
pub fn relieve(level: Level) {
let taken: Vec<(Tier, Box<dyn Fn()>)> = SINKS.with_borrow_mut(std::mem::take);
let mut run = 0usize;
for tier in Tier::ORDER {
if !tier.evicted_at(level) {
continue;
}
for (t, sink) in &taken {
if *t == tier {
sink();
run += 1;
}
}
}
// Put them back, keeping anything a sink registered while it ran — after,
// so the order within a tier stays registration order.
SINKS.with_borrow_mut(|sinks| {
let added = std::mem::replace(sinks, taken);
sinks.extend(added);
});
log::info!("memory pressure ({level:?}): ran {run} eviction(s)");
}
#[cfg(test)]
mod tests {
use super::*;
use std::rc::Rc;
/// Registers one sink per tier, backwards, and hands back what they saw.
fn recorder() -> Rc<RefCell<Vec<Tier>>> {
let seen = Rc::new(RefCell::new(Vec::new()));
for tier in [Tier::Thumbnails, Tier::Proxies, Tier::Gpu] {
let seen = seen.clone();
evict_at(tier, move || seen.borrow_mut().push(tier));
}
seen
}
fn reset() {
SINKS.with_borrow_mut(|s| s.clear());
}
#[test]
fn eviction_runs_cheapest_to_rebuild_first() {
// Registered deliberately backwards, because the guarantee is about
// the tier and not about who registered first. A handler that simply
// ran its list would pass every other assertion here and fail this
// one — and on a device it would throw away thumbnails to keep a
// render pipeline that nothing was going to draw.
reset();
let seen = recorder();
relieve(Level::Critical);
assert_eq!(
*seen.borrow(),
vec![Tier::Gpu, Tier::Proxies, Tier::Thumbnails]
);
reset();
}
#[test]
fn hiding_the_window_costs_only_the_gpu() {
// The cheap moment: give back what a window that is not on screen
// cannot use, and keep what the user will be looking at when they come
// back. Widening this to everything would make every task switch a
// reload of the grid.
reset();
let seen = recorder();
relieve(Level::UiHidden);
assert_eq!(*seen.borrow(), vec![Tier::Gpu]);
reset();
}
#[test]
fn pressure_before_anything_is_registered_is_not_a_failure() {
// The launch window: `android_main` installs the listener before
// `run` builds a single cache, so the first minutes of a cold start
// can deliver a warning to an empty registry.
reset();
relieve(Level::Critical);
}
#[test]
fn a_sink_may_register_another_without_deadlocking() {
// Guards the re-entry the take-and-restore exists for: a sink is
// interface code, and interface code that reached this module again
// would otherwise meet a borrow it already held.
reset();
let seen = Rc::new(RefCell::new(0usize));
{
let seen = seen.clone();
evict_at(Tier::Gpu, move || {
*seen.borrow_mut() += 1;
evict_at(Tier::Thumbnails, || {});
});
}
relieve(Level::Critical);
assert_eq!(*seen.borrow(), 1);
// And the one it added survived, rather than being dropped with the
// temporary list.
assert_eq!(SINKS.with_borrow(|sinks| sinks.len()), 2);
reset();
}
}
+160
View File
@@ -0,0 +1,160 @@
//! TRACES: FR-CULL-3
//! The focus-peaking vocabulary, as the indices a chip row can carry.
//!
//! `dr_gpu` decides what peaking *is* — the measure, the thresholds, the
//! marks. This decides how a menu of three sensitivities and four colours
//! crosses the boundary into Slint, which has no notion of a Rust enum and
//! carries the choice as an `int` into an array of labels.
//!
//! That translation is small and it is the kind of small that goes wrong
//! silently. An index the interface sends that Rust reads as a different
//! variant produces a control that changes something other than what it says,
//! which nobody notices as a bug — they notice it as peaking behaving oddly.
//! So the order lives in one place here, both directions are asserted to round
//! trip, and a test checks that the labels in `ui/peaking.slint` still number
//! the same as the vocabularies they claim to name.
//!
//! Free-standing functions over plain integers, deliberately, for the reason
//! `crate::histogram` gives: none of this needs a GPU, a window or a
//! photograph to be checked, and all of it is invisible when wrong.
use dr_gpu::{PeakColour, PeakSensitivity};
/// The sensitivities, in the order the chip row shows them.
///
/// Least sensitive first, so the row reads left to right as "mark less" to
/// "mark more" — the axis the photographer is actually moving along.
pub(crate) const SENSITIVITIES: [PeakSensitivity; 3] = [
PeakSensitivity::Low,
PeakSensitivity::Medium,
PeakSensitivity::High,
];
/// The mark colours, in the order the chip row shows them.
pub(crate) const COLOURS: [PeakColour; 4] = [
PeakColour::Red,
PeakColour::Yellow,
PeakColour::Cyan,
PeakColour::Magenta,
];
/// The sensitivity an index names.
///
/// Out of range falls back to the default rather than panicking. The index
/// arrives from the interface, and the interface is the half of this that can
/// be recompiled without recompiling the other — a chip row that grew an entry
/// should degrade to a sane setting, not take the application down mid-cull.
pub(crate) fn sensitivity(index: i32) -> PeakSensitivity {
usize::try_from(index)
.ok()
.and_then(|i| SENSITIVITIES.get(i).copied())
.unwrap_or_default()
}
/// The colour an index names, on the same terms.
pub(crate) fn colour(index: i32) -> PeakColour {
usize::try_from(index)
.ok()
.and_then(|i| COLOURS.get(i).copied())
.unwrap_or_default()
}
/// Which chip is lit for this sensitivity.
pub(crate) fn sensitivity_index(value: PeakSensitivity) -> i32 {
SENSITIVITIES.iter().position(|s| *s == value).unwrap_or(0) as i32
}
/// Which chip is lit for this colour.
pub(crate) fn colour_index(value: PeakColour) -> i32 {
COLOURS.iter().position(|c| *c == value).unwrap_or(0) as i32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_variant_appears_exactly_once_in_its_row() {
// A variant missing from the row is a setting the photographer cannot
// reach; one listed twice is two chips that do the same thing, of
// which only the first can ever look selected. Both are invisible in
// the running application until somebody presses the wrong chip.
for s in SENSITIVITIES {
assert_eq!(
SENSITIVITIES.iter().filter(|x| **x == s).count(),
1,
"{s:?} is listed more than once"
);
}
for c in COLOURS {
assert_eq!(COLOURS.iter().filter(|x| **x == c).count(), 1);
}
// Named rather than counted, so adding a variant to `dr_gpu` without
// adding it here fails to compile instead of passing quietly.
assert!(SENSITIVITIES.contains(&PeakSensitivity::Low));
assert!(SENSITIVITIES.contains(&PeakSensitivity::Medium));
assert!(SENSITIVITIES.contains(&PeakSensitivity::High));
assert!(COLOURS.contains(&PeakColour::Red));
assert!(COLOURS.contains(&PeakColour::Yellow));
assert!(COLOURS.contains(&PeakColour::Cyan));
assert!(COLOURS.contains(&PeakColour::Magenta));
}
#[test]
fn an_index_and_its_variant_agree_in_both_directions() {
// The failure this catches is a chip that lights up under the pointer
// while a different setting takes effect — the two directions drifting
// apart is exactly what one shared array is here to prevent, and the
// only way to see it is to go round.
for (i, s) in SENSITIVITIES.iter().enumerate() {
assert_eq!(sensitivity(i as i32), *s);
assert_eq!(sensitivity_index(*s), i as i32);
}
for (i, c) in COLOURS.iter().enumerate() {
assert_eq!(colour(i as i32), *c);
assert_eq!(colour_index(*c), i as i32);
}
}
#[test]
fn an_index_from_nowhere_lands_on_the_default_rather_than_panicking() {
// Slint has no bound on the `int` it sends and Rust has no way to
// refuse one. A panic here would be an application that closes because
// a chip row was edited.
assert_eq!(sensitivity(-1), PeakSensitivity::default());
assert_eq!(sensitivity(99), PeakSensitivity::default());
assert_eq!(colour(-1), PeakColour::default());
assert_eq!(colour(99), PeakColour::default());
}
#[test]
fn the_panel_offers_exactly_the_choices_this_module_knows_about() {
// **The one seam neither compiler checks.** The labels live in
// `ui/peaking.slint` and the meanings live here, joined only by an
// integer; a fifth colour added to the chip row would send index 4 to
// `colour`, which would quietly answer Red. Reading the file is
// clumsier than a derive, and it is what there is.
let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/peaking.slint"))
.expect("the panel this module serves");
let listed = |line_start: &str| -> usize {
let line = src
.lines()
.map(str::trim)
.find(|l| l.starts_with(line_start))
.unwrap_or_else(|| panic!("no `{line_start}` row in peaking.slint"));
line.matches('"').count() / 2
};
assert_eq!(
listed("options: [\"Low\""),
SENSITIVITIES.len(),
"the sensitivity chips and `SENSITIVITIES` disagree"
);
assert_eq!(
listed("options: [\"Red\""),
COLOURS.len(),
"the colour chips and `COLOURS` disagree"
);
}
}
+192
View File
@@ -0,0 +1,192 @@
//! TRACES: FR-DEV-6 | FR-PLAT-LIN-1
//! Reads and writes the named preset library beside the other config.
//!
//! A near-twin of [`SettingsStore`](crate::settings_store::SettingsStore), and
//! separate from it for the reason that one is: two files, two lifetimes.
//! Resetting preferences must not destroy a photographer's presets, and a
//! preset library is the one file here that represents work rather than
//! configuration — it is what someone would carry to another machine.
//!
//! # Why the library is loaded whole and saved whole
//!
//! There is no incremental path. The document is kilobytes (see
//! [`PresetLibrary`]), the caller is holding the copy the user just edited,
//! and a merge would let a preset the window has not drawn yet resurrect
//! after a delete. The same argument the settings store makes about fields,
//! made about entries.
use std::path::{Path, PathBuf};
use dr_pipeline::PresetLibrary;
/// Loads and saves the named preset library.
pub struct PresetStore {
path: PathBuf,
}
impl PresetStore {
/// Open the store at the platform config location.
///
/// Linux: `$XDG_CONFIG_HOME/darkroom/presets.drpl`, falling back to
/// `~/.config` — the same resolution `SettingsStore` does, so the files sit
/// together and a user backing up one takes all of them.
pub fn open() -> Self {
let dir = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config")
})
.join("darkroom");
Self::open_at(dir.join(format!(
"presets.{}",
dr_pipeline::preset::LIBRARY_EXTENSION
)))
}
/// Open at an explicit path — for tests, and for a non-default location.
pub fn open_at(path: PathBuf) -> Self {
Self { path }
}
pub fn path(&self) -> &Path {
&self.path
}
/// The stored library, or an empty one.
///
/// A missing file is a first run. An unparseable one is answered with an
/// empty library rather than an error, on the same reasoning the settings
/// store gives — the alternative is an app that will not start until the
/// user hand-edits a file.
///
/// The difference worth stating: settings are regenerated on the next
/// save, where a preset library is *work*, and rewriting it whole would
/// destroy whatever was in there. So a library that failed to parse is
/// held empty in memory and **not** written back over until the user saves
/// a preset, at which point they have chosen to. Nothing here deletes the
/// file, and the warning names the path so it can be recovered by hand.
pub fn load(&self) -> PresetLibrary {
match std::fs::read_to_string(&self.path) {
Ok(text) => match PresetLibrary::parse(&text) {
Ok(library) => library,
Err(e) => {
log::warn!(
"{} is not a readable preset library ({e}); \
starting empty, the file is left alone",
self.path.display()
);
PresetLibrary::default()
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => PresetLibrary::default(),
Err(e) => {
log::warn!("reading {}: {e}; starting empty", self.path.display());
PresetLibrary::default()
}
}
}
/// Persist the library, replacing whatever was there.
pub fn save(&self, library: &PresetLibrary) -> Result<(), PresetStoreError> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
// Write and rename, so an interrupted save cannot truncate the
// existing file — the same discipline the settings and session stores
// use, and it matters more here because what would be truncated is
// every preset the user has ever made rather than a set of
// preferences that rebuild themselves.
let tmp = self.path.with_extension("tmp");
std::fs::write(&tmp, library.to_text())?;
std::fs::rename(&tmp, &self.path)?;
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum PresetStoreError {
#[error("preset library io: {0}")]
Io(#[from] std::io::Error),
}
#[cfg(test)]
mod tests {
use super::*;
use dr_pipeline::Preset;
/// The same hand-rolled temp directory the settings store's tests use —
/// unique per process and thread, so a parallel run cannot collide.
fn tempdir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"dr-presets-test-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn store(name: &str) -> (PresetStore, PathBuf) {
let dir = tempdir(name);
(
PresetStore::open_at(dir.join("nested").join("presets.drpl")),
dir,
)
}
fn library() -> PresetLibrary {
let mut lib = PresetLibrary::default();
let mut params = std::collections::BTreeMap::new();
params.insert(("exposure".to_string(), "exposure".to_string()), 0.75);
lib.insert("Warm", Preset::from_params(params)).unwrap();
lib.insert("Neutral", Preset::default()).unwrap();
lib
}
#[test]
fn a_library_round_trips_through_the_store() {
let (store, _dir) = store("a-library-round-trips-through-the-store");
store.save(&library()).unwrap();
assert_eq!(store.load(), library());
}
#[test]
fn a_first_run_loads_an_empty_library() {
let (store, _dir) = store("a-first-run-loads-an-empty-library");
assert!(!store.path().exists());
assert!(store.load().is_empty());
}
#[test]
fn saving_creates_the_config_directory() {
let (store, _dir) = store("saving-creates-the-config-directory");
store.save(&library()).unwrap();
assert!(store.path().exists());
}
#[test]
fn an_unreadable_file_is_left_alone_rather_than_overwritten() {
// The difference from the settings store, and the reason this test
// exists: what is on disk is work, so a parse failure must not be the
// moment it is destroyed.
let (store, _dir) = store("an-unreadable-file-is-left-alone-rather-than-overwritten");
std::fs::create_dir_all(store.path().parent().unwrap()).unwrap();
std::fs::write(store.path(), "this is not a preset library").unwrap();
assert!(store.load().is_empty());
assert_eq!(
std::fs::read_to_string(store.path()).unwrap(),
"this is not a preset library"
);
}
#[test]
fn a_neutral_preset_survives_a_save_and_load() {
// It is a real entry, not an absence — see `PresetLibrary::insert`.
let (store, _dir) = store("a-neutral-preset-survives-a-save-and-load");
store.save(&library()).unwrap();
assert_eq!(store.load().get("Neutral"), Some(&Preset::default()));
}
}
+421 -6
View File
@@ -35,10 +35,11 @@ use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use dr_pipeline::{Preset, Scope, Sidecar};
use dr_pipeline::{NameError, Preset, PresetLibrary, Scope, Sidecar};
use slint::ComponentHandle;
use crate::develop::DevelopSession;
use crate::preset_store::PresetStore;
use crate::{library, library_ui, settings_ui, AppWindow, ParamRow};
/// Where the develop view's current edit is stored.
@@ -121,11 +122,21 @@ impl Clipboard {
let Some(preset) = self.preset.borrow().clone() else {
return String::new();
};
match preset.op_count(scope) {
0 => "Neutral".to_string(),
1 => "1 adjustment".to_string(),
n => format!("{n} adjustments"),
}
describe(&preset, scope)
}
}
/// A short description of a preset's contents, for a label.
///
/// Free rather than a method on [`Clipboard`] because the named-preset sheet
/// describes what *saving* would capture, and a second phrasing of the same
/// count is a second thing to keep in step — the sheet saying "3 settings"
/// beside a panel saying "3 adjustments" would read as two different numbers.
pub fn describe(preset: &Preset, scope: Scope) -> String {
match preset.op_count(scope) {
0 => "Neutral".to_string(),
1 => "1 adjustment".to_string(),
n => format!("{n} adjustments"),
}
}
@@ -528,6 +539,308 @@ pub fn wire(
}
}
// ---------------------------------------------------------------------------
// Named presets (FR-DEV-6)
// ---------------------------------------------------------------------------
/// TRACES: FR-DEV-6
/// The saved preset library, and the file it lives in.
///
/// # Why the library is held in memory as well as on disk
///
/// Every change writes the whole file (see [`PresetStore`]), so the in-memory
/// copy is what the sheet is drawn from and what the next edit is applied to.
/// Reading the file back after each change would be the same bytes and one
/// more chance for a failed read to empty a list the user is looking at.
///
/// # A failed save is reported, not swallowed
///
/// The clipboard cannot fail — it is memory. This can: a full disk, a config
/// directory that is not writable. Losing a preset the user just named, with
/// the sheet cheerfully listing it, would be discovered at the worst possible
/// moment, so the write's result reaches the window.
pub struct NamedPresets {
store: PresetStore,
library: RefCell<PresetLibrary>,
}
impl NamedPresets {
/// Load the library from its usual place.
pub fn open() -> Rc<Self> {
Self::at(PresetStore::open())
}
/// Load from an explicit store — for tests, and for a non-default location.
#[cfg(test)]
pub fn open_at(path: PathBuf) -> Rc<Self> {
Self::at(PresetStore::open_at(path))
}
fn at(store: PresetStore) -> Rc<Self> {
let library = RefCell::new(store.load());
Rc::new(Self { store, library })
}
/// The stored names, in the order they are written.
pub fn names(&self) -> Vec<String> {
self.library.borrow().names().map(String::from).collect()
}
/// The preset stored under `name`.
pub fn get(&self, name: &str) -> Option<Preset> {
self.library.borrow().get(name).cloned()
}
/// Store `preset` under `name` and persist.
///
/// The in-memory library is updated first and rolled back if the write
/// fails, so what the sheet lists is always what is on disk. The
/// alternative — writing first — would mean holding a preset the file does
/// not have on every failure path.
fn insert(&self, name: &str, preset: Preset) -> Result<(), SaveError> {
let previous = {
let mut library = self.library.borrow_mut();
let existing = library.get(name.trim()).cloned();
library.insert(name, preset).map_err(SaveError::Name)?;
existing
};
self.persist(|library| match previous {
Some(p) => {
let _ = library.insert(name, p);
}
None => {
library.remove(name.trim());
}
})
}
/// Save the library, undoing the in-memory change if the write fails.
fn persist(&self, rollback: impl FnOnce(&mut PresetLibrary)) -> Result<(), SaveError> {
let result = self.store.save(&self.library.borrow());
match result {
Ok(()) => Ok(()),
Err(e) => {
rollback(&mut self.library.borrow_mut());
// Named, the way the settings page names its file: "could not
// save" without saying where leaves the user nothing to check
// and nothing to fix.
Err(SaveError::Write(format!(
"{}: {e}",
self.store.path().display()
)))
}
}
}
}
/// Why a preset could not be saved.
enum SaveError {
/// The name itself was refused.
Name(NameError),
/// The library could not be written.
Write(String),
}
impl SaveError {
/// What to put in front of the user.
///
/// The prose lives here rather than in `dr-pipeline`, which depends on
/// nothing and has no business holding user-facing strings.
fn message(&self) -> String {
match self {
Self::Name(NameError::Empty) => "Give the preset a name.".to_string(),
Self::Name(NameError::Unrepresentable) => {
"A preset name cannot contain brackets or line breaks.".to_string()
}
Self::Write(e) => format!("Could not save presets: {e}"),
}
}
}
/// Push the stored names onto the window.
pub fn render_named(window: &AppWindow, named: &Rc<NamedPresets>) {
let names: Vec<slint::SharedString> = named.names().into_iter().map(Into::into).collect();
window.set_preset_names(slint::ModelRc::new(slint::VecModel::from(names)));
}
/// Wire saving, applying, renaming and deleting named presets.
///
/// Takes the same [`Develop`] bundle the clipboard wiring does, and for the
/// same reason: applying a preset to the open image changes the graph, so it
/// has to rebuild the panel, redraw the canvas and know where to save.
pub fn wire_named(
window: &AppWindow,
named: Rc<NamedPresets>,
develop: Develop,
settings: Rc<settings_ui::SettingsController>,
library: Rc<library_ui::LibraryController>,
collections: Rc<crate::collections_ui::CollectionsController>,
) {
let Develop {
session,
rows,
redraw,
open,
} = develop;
render_named(window, &named);
// --- save the open edit under a name ---------------------------------
{
let weak = window.as_weak();
let named = named.clone();
let session = session.clone();
window.on_save_preset(move |name| {
let Some(w) = weak.upgrade() else { return };
let Some(preset) = session.borrow().as_ref().map(|s| s.copy_settings()) else {
w.set_preset_name_error("Open a photograph first.".into());
return;
};
// Captured at full scope, exactly as a copy is: the scope is a
// decision about applying, and a preset that had already discarded
// the crop could never grow it back (see `Preset::capture`).
match named.insert(&name, preset) {
Ok(()) => {
w.set_preset_name_error(Default::default());
render_named(&w, &named);
}
Err(e) => w.set_preset_name_error(e.message().into()),
}
});
}
// A refusal the user has started correcting is stale, and a message that
// outlives its cause is one the user learns to ignore.
{
let weak = window.as_weak();
window.on_preset_name_edited(move |_| {
if let Some(w) = weak.upgrade() {
w.set_preset_name_error(Default::default());
}
});
}
// --- what saving would capture ----------------------------------------
{
let weak = window.as_weak();
let session = session.clone();
window.on_presets_opened(move || {
let Some(w) = weak.upgrade() else { return };
// At the scope a save would use, which is full: a preset keeps
// the framing it was captured with and drops it at apply time.
let summary = session
.borrow()
.as_ref()
.map(|s| describe(&s.copy_settings(), Scope::Everything))
.unwrap_or_default();
w.set_preset_capture_summary(summary.into());
});
}
// --- apply ------------------------------------------------------------
//
// One callback for both targets. Which one is meant is not a guess: the
// sheet was opened from a view that set `preset-apply-count`, and the
// label the user just read said "Applies to 12 selected photographs" or
// said nothing. Deciding here from the same number keeps the promise.
{
let weak = window.as_weak();
let named = named.clone();
let settings = settings.clone();
let library = library.clone();
let collections = collections.clone();
let session = session.clone();
let rows = rows.clone();
let redraw = redraw.clone();
let open = open.clone();
window.on_apply_preset(move |name| {
let Some(w) = weak.upgrade() else { return };
let Some(preset) = named.get(&name) else {
// Another window may have deleted it since this list was drawn.
render_named(&w, &named);
return;
};
let scope = scope_for(&settings.snapshot());
if w.get_preset_apply_count() > 0 {
library_ui::paste_settings_to_selection(
&w,
&library,
&collections.selected(),
&preset,
scope,
);
} else {
{
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return };
s.apply_settings(&preset, scope);
}
// The same three steps a paste takes, for the same reasons:
// many controls moved without any of them being touched, and
// a deliberate discrete action is saved immediately.
crate::sync_rows(&w, &rows, &session);
redraw(&w);
save_open_edit(&w, &open.borrow(), &session, &library);
}
w.set_presets_open(false);
});
}
// --- rename -----------------------------------------------------------
{
let weak = window.as_weak();
let named = named.clone();
window.on_rename_preset(move |from, to| {
let Some(w) = weak.upgrade() else { return };
let renamed = {
let mut library = named.library.borrow_mut();
library.rename(&from, &to)
};
match renamed {
Ok(_) => {
// Nothing to roll back to on a failed write beyond the
// name it had, which is what this restores.
let from = from.to_string();
let to = to.to_string();
if let Err(e) = named.persist(move |library| {
let _ = library.rename(&to, &from);
}) {
w.set_preset_name_error(e.message().into());
}
}
Err(e) => w.set_preset_name_error(SaveError::Name(e).message().into()),
}
render_named(&w, &named);
});
}
// --- delete -----------------------------------------------------------
{
let weak = window.as_weak();
let named = named.clone();
window.on_delete_preset(move |name| {
let Some(w) = weak.upgrade() else { return };
let removed = {
let mut library = named.library.borrow_mut();
let previous = library.get(&name).cloned();
library.remove(&name);
previous
};
if let Some(previous) = removed {
let name = name.to_string();
if let Err(e) = named.persist(move |library| {
let _ = library.insert(&name, previous);
}) {
w.set_preset_name_error(e.message().into());
}
}
render_named(&w, &named);
});
}
}
/// Seconds since the epoch, or zero if the clock is before it.
///
/// Zero rather than a panic: a wrong timestamp costs a tie-break in the merge,
@@ -804,4 +1117,106 @@ mod tests {
assert!(clipboard.is_armed());
assert_eq!(clipboard.describe(Scope::Adjustments), "Neutral");
}
// -----------------------------------------------------------------------
// Named presets
// -----------------------------------------------------------------------
fn named(name: &str) -> (Rc<NamedPresets>, PathBuf) {
let dir = tempdir(name);
(NamedPresets::open_at(dir.join("presets.drpl")), dir)
}
#[test]
fn a_saved_preset_is_on_disk_before_the_call_returns() {
// Not on the way out, and not on a timer: a preset the user named and
// then lost to a crash is the one failure this feature cannot have.
let (presets, dir) = named("saved-immediately");
presets
.insert("Warm", Preset::capture(&edited()))
.ok()
.expect("saved");
let reloaded = NamedPresets::open_at(dir.join("presets.drpl"));
assert_eq!(reloaded.names(), vec!["Warm".to_string()]);
}
#[test]
fn a_saved_preset_carries_the_edit_it_captured() {
let (presets, _dir) = named("carries-the-edit");
presets.insert("Warm", Preset::capture(&edited())).ok();
let preset = presets.get("Warm").expect("stored");
let mut target = EditGraph::default_chain();
preset.apply(&mut target, Scope::Adjustments);
assert_eq!(
target.param(
dr_pipeline::ops::exposure::ID,
dr_pipeline::ops::exposure::EXPOSURE
),
Some(1.5)
);
}
#[test]
fn a_name_that_cannot_be_stored_is_refused_rather_than_mangled() {
let (presets, _dir) = named("refused-name");
assert!(presets.insert("", Preset::default()).is_err());
assert!(presets.insert("bracket]", Preset::default()).is_err());
assert!(presets.names().is_empty());
}
#[test]
fn a_write_that_fails_leaves_the_list_showing_what_is_on_disk() {
// The rollback. A sheet listing a preset the file does not have is a
// loss the user discovers later, at the moment they reach for it.
let dir = tempdir("failed-write");
// A *file* where the store wants a directory, so `create_dir_all`
// fails and the save cannot succeed.
let blocked = dir.join("blocked");
std::fs::write(&blocked, b"not a directory").unwrap();
let presets = NamedPresets::open_at(blocked.join("presets.drpl"));
assert!(presets.insert("Warm", Preset::default()).is_err());
assert!(
presets.names().is_empty(),
"the failed save left a preset behind"
);
}
#[test]
fn a_failed_overwrite_puts_the_original_back() {
// The other half of the rollback, and the one that loses work if it is
// wrong: overwriting is destructive, so a failed overwrite has to
// restore what was there rather than leave the name holding the new
// value the file never received.
use std::os::unix::fs::PermissionsExt;
let dir = tempdir("failed-overwrite");
let path = dir.join("presets.drpl");
let presets = NamedPresets::open_at(path.clone());
presets.insert("Warm", Preset::capture(&edited())).ok();
let original = presets.get("Warm").expect("stored");
// The directory exists, so `create_dir_all` still succeeds and it is
// the write of the temporary file that fails — which is the path a
// full disk takes.
let mut perms = std::fs::metadata(&dir).unwrap().permissions();
perms.set_mode(0o500);
std::fs::set_permissions(&dir, perms.clone()).unwrap();
let failed = presets.insert("Warm", Preset::default()).is_err();
// Restore the permissions before asserting, so a failure here does not
// leave an undeletable directory behind for the next run.
perms.set_mode(0o700);
std::fs::set_permissions(&dir, perms).unwrap();
assert!(failed, "the write should have failed");
assert_eq!(
presets.get("Warm"),
Some(original),
"the failed overwrite kept the new value"
);
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! TRACES: FR-PLAT-LIN-1 | FR-NC-6a | FR-EXP-5
//! TRACES: FR-PLAT-LIN-1 | FR-NC-6a | FR-EXP-5 | NFR-OPS-3
//! Reads and writes `settings.json` beside the session config.
//!
//! Deliberately a near-twin of [`SessionStore`](dr_sync_nextcloud::SessionStore)