Run the library from local data when the server is unreachable
Also carries in-flight work that shared these files: the zoom structure-key fix in the adjust pipeline, nearest-neighbour filtering past 1:1, the timeline scrub marker correction, the 423-Locked retry in the metadata sweep, and the thumbnail size-class migration. # Offline mode (FR-CAT-9) The app previously assumed the server was reachable and treated its absence as a series of unrelated per-operation failures. A launch without a connection produced an empty grid, even with a complete catalog on disk and every thumbnail already in the shards. Reachability is now inferred from traffic the app was already making, rather than probed for. `RemoteError::indicates_offline` draws the line that makes this possible: a dead connection is offline, a 403 or a 500 is not — the server answered, so blanking the library over one forbidden file would be a worse error than the one being reported. `Reachability` turns those outcomes into a state, so a library browsing happily never issues a probe at all. Going offline takes one failure, because the user is already experiencing it. Coming back requires evidence — a completed scan or a fetched thumbnail — with a capped exponential backoff behind the manual retry, so twelve sweep lanes failing together do not schedule twelve immediate probes. What keeps working: the catalog opens even when the scan that normally provides it failed, so the grid fills from the last successful scan. Thumbnails come from the shards. Rating, flagging and collecting are catalog writes that never touched the network. What stops is opening an original that was never stored locally, and it now says so in those words instead of reporting "network error: connection refused" over a photograph. Work that is pure network is refused rather than left to fail slowly: the metadata sweep, derived sync, and sidecar writes. The sweep would otherwise spend a timeout per image across the whole library while the progress bar implied something was happening. Deferring sidecars is a real gap rather than a hidden one — a rating made offline reaches its sidecar only when that image is judged again while connected — and it is recorded as such at the call site. # The "On this device" filter A chip beside the rating filters, narrowing the grid to images whose original is held locally. It composes with the rating terms rather than replacing them, so "five-star frames I can actually edit on this train" is one filter. The predicate is SQL, like the rating terms and for the same reason: the count in the header has to agree with the cells drawn. It reads `image_cache.tier_actual`, which nothing writes yet — the next commit fills it. Until then the chip honestly reports zero. `Tier` gains an explicit on-disk encoding. The variants are ordered by generosity and the derived `Ord` invites reordering them, which would silently reinterpret every cached row; the round-trip test is what holds the two in agreement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+137
-1
@@ -229,6 +229,42 @@ fn reset_view_state(window: &AppWindow) {
|
||||
window.set_crop_y(0.0);
|
||||
window.set_crop_w(1.0);
|
||||
window.set_crop_h(1.0);
|
||||
window.set_max_straighten(dr_pipeline::framing::MAX_STRAIGHTEN);
|
||||
window.set_straighten(0.0);
|
||||
window.set_flip_h(false);
|
||||
window.set_flip_v(false);
|
||||
window.set_framing_modified(false);
|
||||
}
|
||||
|
||||
/// Push the framing back to the geometry panel.
|
||||
///
|
||||
/// Separate from [`sync_rows`] because framing is no longer *in* the rows —
|
||||
/// it is presented by its own panel rather than generated (see
|
||||
/// `DevelopSession::rows`), so nothing else would carry these values across.
|
||||
///
|
||||
/// Everything here is written from what the session actually holds rather than
|
||||
/// from what the gesture asked for: quarter turns wrap, the angle is clamped
|
||||
/// to the descriptor's range, and the crop is normalised, so the panel must
|
||||
/// show the applied value or it will disagree with the image.
|
||||
fn sync_framing(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>>) {
|
||||
let Some(s) = session.borrow().as_ref().map(|s| {
|
||||
let (h, v) = s.flips();
|
||||
let c = s.crop();
|
||||
(s.angle(), h, v, s.framing_edits_image(), c)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let (angle, flip_h, flip_v, modified, crop) = s;
|
||||
window.set_straighten(angle);
|
||||
window.set_flip_h(flip_h);
|
||||
window.set_flip_v(flip_v);
|
||||
window.set_framing_modified(modified);
|
||||
// The overlay draws from these, and a rotation re-expresses the rect —
|
||||
// so they have to follow a quarter turn even though no handle moved.
|
||||
window.set_crop_x(crop.x);
|
||||
window.set_crop_y(crop.y);
|
||||
window.set_crop_w(crop.width);
|
||||
window.set_crop_h(crop.height);
|
||||
}
|
||||
|
||||
/// Push current parameter values back to the interface.
|
||||
@@ -249,6 +285,13 @@ fn sync_rows(
|
||||
) {
|
||||
use slint::Model as _;
|
||||
|
||||
// Framing is not in `rows` — it has its own panel — but it *is* parameter
|
||||
// state that the core may have clamped, so it is pushed back here for the
|
||||
// same reason and by the same callers. A `reset all` reaches the framing
|
||||
// too, and without this the geometry panel would keep showing the angle
|
||||
// and flips of an image that no longer has them.
|
||||
sync_framing(window, session);
|
||||
|
||||
let current = match session.borrow().as_ref() {
|
||||
Some(s) => s.rows(),
|
||||
None => Vec::new(),
|
||||
@@ -569,6 +612,13 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
// value that was actually applied.
|
||||
window.set_zoom(s.zoom());
|
||||
window.set_zoomed(s.is_zoomed());
|
||||
// Filtering follows the magnification, measured against the
|
||||
// *full* viewport rather than `w`/`h`: a draft frame is
|
||||
// rendered at half resolution, and letting that flip the
|
||||
// canvas to smooth would make it change character for the
|
||||
// duration of every gesture.
|
||||
let (vw, vh) = *viewport.borrow();
|
||||
window.set_magnified(s.magnifies_source(vw, vh));
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("render failed: {e}");
|
||||
@@ -804,7 +854,17 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::warn!("{name}: {e}");
|
||||
w.set_load_error(e.into());
|
||||
// Offline needs its own words. "network error:
|
||||
// connection refused" over a photograph the user
|
||||
// just clicked reads as a broken app; the real
|
||||
// situation is that this particular image was
|
||||
// never stored on this device, and the fix is to
|
||||
// download it while there is a connection.
|
||||
w.set_load_error(if e.offline {
|
||||
"Offline — this image is not stored on this device.".into()
|
||||
} else {
|
||||
slint::SharedString::from(e.message)
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -995,11 +1055,87 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
w.set_crop_y(c.y);
|
||||
w.set_crop_w(c.width);
|
||||
w.set_crop_h(c.height);
|
||||
w.set_framing_modified(s.framing_edits_image());
|
||||
}
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- rotation, flips and straightening -------------------------------
|
||||
//
|
||||
// Framing edits, so unlike zoom and pan they mark the image modified — but
|
||||
// they are reached through named session actions rather than through a row
|
||||
// index, so they do not `sync_rows` either. `sync_framing` is what carries
|
||||
// the applied value back.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.on_rotate_quarters(move |turns| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.rotate_quarters(turns);
|
||||
}
|
||||
sync_framing(&w, &session);
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.on_flip_h_toggled(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.toggle_flip_h();
|
||||
}
|
||||
sync_framing(&w, &session);
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.on_flip_v_toggled(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.toggle_flip_v();
|
||||
}
|
||||
sync_framing(&w, &session);
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.on_straighten_changed(move |degrees| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.set_angle(degrees);
|
||||
}
|
||||
sync_framing(&w, &session);
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
{
|
||||
// The geometry section's reset: crop, angle, rotation and flips back
|
||||
// to neutral, leaving every colour adjustment where it is. The panel's
|
||||
// own reset-all is the one that clears everything.
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.on_framing_reset(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.reset_framing();
|
||||
}
|
||||
sync_framing(&w, &session);
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let index = index.clone();
|
||||
|
||||
Reference in New Issue
Block a user