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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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)
|
||||
|
||||
Reference in New Issue
Block a user