Add thumbnail size classes and grid zoom; fix the scrub ordinal

The grid now zooms, which needs thumbnails at two resolutions rather than
one, and exposed a scrub that landed in the wrong place.

**Two thumbnail size classes.** `ThumbSize::Grid` (256px, ~10 KB) and
`Large` (1024px, ~45 KB), with the class part of the store key so both
coexist. Storing everything large would take the reference library from
~200 MB to ~860 MB, and shards sync, so that is transfer cost on every
device rather than only disk. A store written before the class existed
migrates in place: its entries are all grid-sized, which is what the
column defaults to, so nothing already fetched is discarded.

`forget` now drops every size for an image. Reading a single row left the
other class's bytes on the shard's tally for good, sealing it early on
space nothing occupied.

**Grid zoom.** Ctrl+wheel and pinch resize cells between 90px and 420px in
geometric steps, so the gesture feels the same at either end where a fixed
pixel step would be imperceptible at 400px and violent at 90px. Crossing
256px switches to the large class, so a zoomed cell is sharp rather than
upscaled. Columns and window capacity already derived from cell size, so
the grid reflows for free.

**The scrub landed about half a library too high.** It counted only dated
images while the grid shows all of them — 10,733 dated against 19,841
rows — and ignored `shadowed_by`. Verified against the live catalog: the
old formula gave 10,887, the new one 10,732, the true grid position
10,732. The scrub's count and the grid's window must use identical
predicates and ordering; a test now fails if they diverge.

**Timeline gestures are continuous.** Scrub and pan were quantised to
whole buckets, so a slow drag did nothing until it crossed a boundary and
then jumped a month. Both work in fractions of the visible span now, and
pinch-to-zoom arrives for tablet, where there is no wheel to reach the
axis with.

The pinch accumulator was wrong on first writing: it took at most one step
per update, so an 8x spread — three doublings — yielded one zoom level.
`log2().trunc()` now extracts every whole doubling and carries the
remainder. The original test asserted the wrong number and defended it in
a comment, which is worth remembering: a test can entrench a bug as
readily as catch one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 21:58:32 +02:00
co-authored by Claude Opus 5
parent 75ce3846c3
commit d49b4b41de
13 changed files with 1020 additions and 156 deletions
+111 -12
View File
@@ -166,38 +166,137 @@ fn map_err(e: keyring::Error) -> SecretError {
}
}
/// Placeholder for platforms without an implementation yet.
/// Keystore-backed implementation (FR-PLAT-AND-1).
///
/// Android needs Keystore-backed storage via JNI (FR-PLAT-AND-1). Failing
/// loudly is deliberate: a silent no-op store would look like it worked and
/// then lose the credential.
#[cfg(not(all(unix, not(target_os = "android"))))]
pub struct PlatformSecretStore;
/// `android-native-keyring-store` encrypts each secret with an AES-GCM key
/// held in `AndroidKeyStore` and files the ciphertext in SharedPreferences.
/// The key never leaves the Keystore, so the preferences file is useless on
/// its own. This is the current approach rather than the deprecated
/// `EncryptedSharedPreferences` (REQ §11).
///
/// It finds the JavaVM and Context through `ndk-context`, which
/// `android-activity` initialises before `android_main` is called. Nothing
/// here is usable before that point — hence the lazy handle below.
#[cfg(target_os = "android")]
pub struct PlatformSecretStore {
/// Built on first use, not in `new()`: construction needs the ndk-context
/// to be live, and `new()` may run early. Cached because store names are
/// unique — building one per call would fail on the second call.
store: std::sync::OnceLock<Result<std::sync::Arc<android_native_keyring_store::Store>, String>>,
}
#[cfg(not(all(unix, not(target_os = "android"))))]
#[cfg(target_os = "android")]
impl PlatformSecretStore {
pub fn new() -> Self {
Self
Self {
store: std::sync::OnceLock::new(),
}
}
fn store(&self) -> Result<&std::sync::Arc<android_native_keyring_store::Store>, SecretError> {
self.store
.get_or_init(|| {
android_native_keyring_store::Store::new().map_err(|e| e.to_string())
})
.as_ref()
.map_err(|e| SecretError::Unavailable(e.clone()))
}
/// A credential specifier for one secret. Filed under the same
/// service/key pair as the Linux path, so the two platforms agree on
/// naming even though the backing stores differ.
fn entry(
&self,
r: &SecretRef,
) -> Result<keyring_core::Entry, SecretError> {
use keyring_core::api::CredentialStoreApi;
self.store()?
.build(SERVICE, &r.entry_key(), None)
.map_err(map_err)
}
}
#[cfg(not(all(unix, not(target_os = "android"))))]
#[cfg(target_os = "android")]
impl Default for PlatformSecretStore {
fn default() -> Self {
Self::new()
}
}
#[cfg(not(all(unix, not(target_os = "android"))))]
#[cfg(target_os = "android")]
impl SecretStore for PlatformSecretStore {
fn store(&self, secret_ref: &SecretRef, secret: &str) -> Result<(), SecretError> {
use keyring_core::api::CredentialApi;
self.entry(secret_ref)?.set_password(secret).map_err(map_err)
}
fn retrieve(&self, secret_ref: &SecretRef) -> Result<String, SecretError> {
use keyring_core::api::CredentialApi;
self.entry(secret_ref)?.get_password().map_err(map_err)
}
fn delete(&self, secret_ref: &SecretRef) -> Result<(), SecretError> {
use keyring_core::api::CredentialApi;
match self.entry(secret_ref)?.delete_credential() {
Ok(()) => Ok(()),
// Logout must be idempotent, as on Linux.
Err(keyring_core::Error::NoEntry) => Ok(()),
Err(e) => Err(map_err(e)),
}
}
fn is_available(&self) -> bool {
// Unlike Linux there is no daemon to be absent: if the store builds,
// Keystore is there. Building is the whole probe.
self.store().is_ok()
}
}
#[cfg(target_os = "android")]
fn map_err(e: keyring_core::Error) -> SecretError {
match e {
keyring_core::Error::NoEntry => SecretError::NotFound,
keyring_core::Error::NoStorageAccess(e) => SecretError::Unavailable(e.to_string()),
keyring_core::Error::PlatformFailure(e) => SecretError::Unavailable(e.to_string()),
other => SecretError::Other(other.to_string()),
}
}
/// Placeholder for platforms without an implementation yet.
///
/// Failing loudly is deliberate: a silent no-op store would look like it
/// worked and then lose the credential.
#[cfg(not(any(all(unix, not(target_os = "android")), target_os = "android")))]
pub struct PlatformSecretStore;
#[cfg(not(any(all(unix, not(target_os = "android")), target_os = "android")))]
impl PlatformSecretStore {
pub fn new() -> Self {
Self
}
}
#[cfg(not(any(all(unix, not(target_os = "android")), target_os = "android")))]
impl Default for PlatformSecretStore {
fn default() -> Self {
Self::new()
}
}
#[cfg(not(any(all(unix, not(target_os = "android")), target_os = "android")))]
impl SecretStore for PlatformSecretStore {
fn store(&self, _r: &SecretRef, _s: &str) -> Result<(), SecretError> {
Err(SecretError::Unavailable(
"Keystore-backed storage is not implemented on this platform yet".into(),
"no secret store is implemented for this platform".into(),
))
}
fn retrieve(&self, _r: &SecretRef) -> Result<String, SecretError> {
Err(SecretError::Unavailable(
"Keystore-backed storage is not implemented on this platform yet".into(),
"no secret store is implemented for this platform".into(),
))
}
fn delete(&self, _r: &SecretRef) -> Result<(), SecretError> {