Merge branch 'master' into feat/library-toolbar
# Conflicts: # docs/gestures.md # docs/traceability.md
This commit is contained in:
@@ -0,0 +1,193 @@
|
|||||||
|
name: Benchmarks
|
||||||
|
|
||||||
|
# The suite docs/requirements.md §8 has been promising since it was written:
|
||||||
|
# "an automated benchmark suite against a synthetic 50k catalog, run per-commit
|
||||||
|
# … A regression beyond stated tolerance fails the build."
|
||||||
|
#
|
||||||
|
# Its own workflow rather than a step inside build-and-test.yml, and the reason
|
||||||
|
# is what a failure here means. A red `Build and test` says the code is wrong; a
|
||||||
|
# red `Benchmarks` says the code is slower than it was, which is a different
|
||||||
|
# conversation, is read by different people, and must not be reachable by
|
||||||
|
# retrying a flaky compile.
|
||||||
|
#
|
||||||
|
# # Why this is split in two
|
||||||
|
#
|
||||||
|
# §8 names "the reference desktop", not CI, and it is right to. So:
|
||||||
|
#
|
||||||
|
# cpu — runs on every push. It needs no adapter and no display, and the
|
||||||
|
# budgets it asserts (a 50k catalog opening inside two seconds) have
|
||||||
|
# two orders of magnitude of headroom, so a modest runner can be held
|
||||||
|
# to them honestly. Machine-sensitive budgets — throughput targets
|
||||||
|
# written for a 24-thread desktop — are reported here rather than
|
||||||
|
# asserted; `dr-bench` decides that per metric and says so in its
|
||||||
|
# report. Asserting them on a two-core container would produce exactly
|
||||||
|
# what core/dr-gpu/tests/frame_budget.rs refused to produce: a red gate
|
||||||
|
# everybody learns to ignore.
|
||||||
|
#
|
||||||
|
# gpu — the frame budget, which already exists and already skips itself where
|
||||||
|
# there is no adapter. Not on push: it would build wgpu and naga on
|
||||||
|
# every commit to establish, every time, that this runner has no GPU. It
|
||||||
|
# runs on demand (Actions → Run workflow) so that a runner that *does*
|
||||||
|
# have one can be pointed at it, and the numbers it produces belong in
|
||||||
|
# docs/frame-budget.md by hand, as they already are.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, master, develop]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cpu:
|
||||||
|
runs-on: linux/amd64
|
||||||
|
name: CPU and I/O (per commit)
|
||||||
|
# Node for actions/checkout and actions/cache, which the bare runner image
|
||||||
|
# cannot execute. Rust is installed below.
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Same reasoning as the desktop job in build-and-test.yml: incremental
|
||||||
|
# state exists to make the *second* build in a working tree fast, which is
|
||||||
|
# not a thing a fresh checkout has, and it fills the runner's disk.
|
||||||
|
CARGO_INCREMENTAL: 0
|
||||||
|
# The fixture, out of the workspace so actions/cache never picks it up.
|
||||||
|
# A 14 MB synthetic catalog is two seconds to regenerate and would
|
||||||
|
# otherwise be uploaded and downloaded on every push to save them.
|
||||||
|
DR_BENCH_DIR: /tmp/darkroom-bench
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# No `git lfs pull` here, deliberately. `dr-bench` depends on the catalog,
|
||||||
|
# the decoder, the thumbnail store and the encoder, and on nothing that
|
||||||
|
# reaches `dr-segment` — so the model this repository keeps in LFS is not
|
||||||
|
# part of this job's dependency graph and fetching it would be a minute
|
||||||
|
# spent on a file nothing opens.
|
||||||
|
|
||||||
|
- name: Cache cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: bench-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
# Pinned to the workspace rust-version, as every other job here is: a
|
||||||
|
# floating toolchain turns an unrelated push into a mystery failure, and
|
||||||
|
# for a benchmark it would turn one into a mystery *regression*.
|
||||||
|
#
|
||||||
|
# rust-analyzer is named for the reason build-and-test.yml gives: rustup
|
||||||
|
# reconciles rust-toolchain.toml on the first cargo call whatever this
|
||||||
|
# step asks for, so naming it keeps the download inside the step that says
|
||||||
|
# it is installing things.
|
||||||
|
- name: Install Rust 1.92.0
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
curl -fsSL https://sh.rustup.rs | sh -s -- \
|
||||||
|
-y --no-modify-path --profile minimal --default-toolchain 1.92.0 \
|
||||||
|
--component rust-analyzer
|
||||||
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
# `-p dr-bench`, not `--workspace`. The whole point of that crate having
|
||||||
|
# no GPU and no UI dependency is that this job resolves the catalog, the
|
||||||
|
# decoder and the encoders and stops there — a few minutes rather than the
|
||||||
|
# release build of Slint and wgpu the desktop job pays for.
|
||||||
|
#
|
||||||
|
# Release, and it is not optional: the workspace builds its own crates at
|
||||||
|
# opt-level = 0 in dev, and every figure this produces is dominated by
|
||||||
|
# this workspace's own code. A debug run would measure rustc.
|
||||||
|
- name: Build the suite
|
||||||
|
run: cargo build --release -p dr-bench
|
||||||
|
|
||||||
|
# Exit 1 is a violated budget or a regression past tolerance; exit 2 is
|
||||||
|
# the harness failing to run at all. Both fail the job, and the report
|
||||||
|
# above the failure says which.
|
||||||
|
- name: Measure, and gate
|
||||||
|
run: cargo run --release -p dr-bench -- check
|
||||||
|
|
||||||
|
- name: Disk after
|
||||||
|
if: always()
|
||||||
|
run: df -h /workspace 2>/dev/null || df -h .
|
||||||
|
|
||||||
|
gpu:
|
||||||
|
# On demand only — see the header. A runner with a Vulkan device can be
|
||||||
|
# pointed at this; one without will skip the measurement and say so, which
|
||||||
|
# is the same posture the rest of this repository's device tests take.
|
||||||
|
if: github.event_name == 'workflow_dispatch'
|
||||||
|
runs-on: linux/amd64
|
||||||
|
name: Frame budget (on demand)
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_INCREMENTAL: 0
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# `dr-gpu` depends on `dr-segment` for the watershed's pixel passes. Its
|
||||||
|
# default features are off, so no weights are compiled in — but the fetch
|
||||||
|
# is cheap insurance and its failure is not fatal. The header of the same
|
||||||
|
# step in build-and-test.yml explains why the extraheader is stripped
|
||||||
|
# rather than reused: two Authorization headers is a 400 from Gitea, one
|
||||||
|
# step after the batch call that had just succeeded.
|
||||||
|
- name: Fetch the segmentation model
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
LFS_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
git lfs install --local
|
||||||
|
git config --local --get-regexp '^http\..*extraheader$' \
|
||||||
|
| cut -d' ' -f1 | sort -u \
|
||||||
|
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||||
|
git config --local lfs.url \
|
||||||
|
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||||
|
git lfs pull
|
||||||
|
|
||||||
|
- name: Cache cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: bench-gpu-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: Build dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq pkg-config libfontconfig1-dev libxkbcommon-dev
|
||||||
|
|
||||||
|
- name: Install Rust 1.92.0
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
curl -fsSL https://sh.rustup.rs | sh -s -- \
|
||||||
|
-y --no-modify-path --profile minimal --default-toolchain 1.92.0 \
|
||||||
|
--component rust-analyzer
|
||||||
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
# The guard, in release. Its own module documentation is explicit that a
|
||||||
|
# release run checks strictly more than a dev one: the CPU half of a frame
|
||||||
|
# is shader-string assembly, which is several times slower unoptimised, so
|
||||||
|
# it is folded into the assertion only when debug_assertions is off.
|
||||||
|
#
|
||||||
|
# With no adapter this prints "skipping: no GPU adapter" and passes. A
|
||||||
|
# test that cannot run is not evidence either way, and turning that into a
|
||||||
|
# failure would make the job useless on the runner it usually lands on.
|
||||||
|
- name: Frame budget (FR-DSP-3)
|
||||||
|
run: cargo test --release -p dr-gpu --test frame_budget -- --nocapture
|
||||||
|
|
||||||
|
# The instrument behind docs/frame-budget.md. It exits non-zero with no
|
||||||
|
# adapter, which is right for a tool a person runs deliberately and wrong
|
||||||
|
# for a job that usually has none — hence continue-on-error. Its table is
|
||||||
|
# in the log for whoever asked for this run; the committed numbers are
|
||||||
|
# still updated by hand, as that file says.
|
||||||
|
- name: Frame budget table
|
||||||
|
continue-on-error: true
|
||||||
|
run: cargo run --release -p dr-gpu --example frame_budget
|
||||||
@@ -83,6 +83,21 @@ GPU tests skip themselves where there is no adapter rather than failing — a
|
|||||||
test that cannot run is not evidence either way — so a green run on a machine
|
test that cannot run is not evidence either way — so a green run on a machine
|
||||||
without a GPU is expected, and does not mean the GPU paths were exercised.
|
without a GPU is expected, and does not mean the GPU paths were exercised.
|
||||||
|
|
||||||
|
There is a fifth check, and it is not in that list because you are unlikely to
|
||||||
|
break it by accident:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --release -p dr-bench -- check
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the benchmark suite (`docs/requirements.md` §8), which builds a
|
||||||
|
synthetic 50,000-image catalog and fails the build if a performance target is
|
||||||
|
missed or a measurement has drifted past its tolerance. It runs on every push in
|
||||||
|
its own workflow. [`docs/benchmarks.md`](docs/benchmarks.md) says what it
|
||||||
|
measures, what it deliberately does not, and how to read a failure. If you have
|
||||||
|
touched the catalog, the decoder, the thumbnail store or the exporter, run it
|
||||||
|
before you send.
|
||||||
|
|
||||||
## Requirements and traceability
|
## Requirements and traceability
|
||||||
|
|
||||||
[`requirements.md`](docs/requirements.md) is the register of record.
|
[`requirements.md`](docs/requirements.md) is the register of record.
|
||||||
@@ -150,6 +165,7 @@ One commit per change. If you fixed two things, that is two commits.
|
|||||||
| [`core/dr-pipeline/ops/README.md`](core/dr-pipeline/ops/README.md) | Adding or changing a develop operation — start here regardless |
|
| [`core/dr-pipeline/ops/README.md`](core/dr-pipeline/ops/README.md) | Adding or changing a develop operation — start here regardless |
|
||||||
| [`docs/architecture.md`](docs/architecture.md) | Anything touching the render path, catalog or sync |
|
| [`docs/architecture.md`](docs/architecture.md) | Anything touching the render path, catalog or sync |
|
||||||
| [`docs/code-health.md`](docs/code-health.md) | Deciding what to work on; grades each seam by what it costs |
|
| [`docs/code-health.md`](docs/code-health.md) | Deciding what to work on; grades each seam by what it costs |
|
||||||
|
| [`docs/benchmarks.md`](docs/benchmarks.md) | A change that could plausibly cost time or memory |
|
||||||
| [`docs/technical-debt.md`](docs/technical-debt.md) | Something looks wrong — check it was not chosen |
|
| [`docs/technical-debt.md`](docs/technical-debt.md) | Something looks wrong — check it was not chosen |
|
||||||
| [`docs/distribution.md`](docs/distribution.md) | Packaging a build, or adding a permission to one |
|
| [`docs/distribution.md`](docs/distribution.md) | Packaging a build, or adding a permission to one |
|
||||||
| [`docs/requirements.md`](docs/requirements.md) | Reference, not reading |
|
| [`docs/requirements.md`](docs/requirements.md) | Reference, not reading |
|
||||||
|
|||||||
Generated
+19
@@ -1224,6 +1224,7 @@ name = "darkroom-android"
|
|||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
|
"dr-plat",
|
||||||
"dr-sync",
|
"dr-sync",
|
||||||
"dr-ui",
|
"dr-ui",
|
||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
@@ -1236,6 +1237,7 @@ name = "darkroom-desktop"
|
|||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"dr-plat",
|
||||||
"dr-ui",
|
"dr-ui",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"log",
|
"log",
|
||||||
@@ -1403,6 +1405,23 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dr-bench"
|
||||||
|
version = "0.9.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"dr-catalog",
|
||||||
|
"dr-decode",
|
||||||
|
"dr-export",
|
||||||
|
"dr-thumbs",
|
||||||
|
"dr-types",
|
||||||
|
"env_logger",
|
||||||
|
"log",
|
||||||
|
"rusqlite",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-catalog"
|
name = "dr-catalog"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ members = [
|
|||||||
"ui/dr-ui",
|
"ui/dr-ui",
|
||||||
"apps/darkroom-desktop",
|
"apps/darkroom-desktop",
|
||||||
"apps/darkroom-android",
|
"apps/darkroom-android",
|
||||||
|
"tools/bench",
|
||||||
"tools/traceability",
|
"tools/traceability",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ dr-ui.workspace = true
|
|||||||
# For `account::set_data_dir`: only the platform entry point knows where Android
|
# For `account::set_data_dir`: only the platform entry point knows where Android
|
||||||
# lets this app keep files, and it must be set before any store is opened.
|
# lets this app keep files, and it must be set before any store is opened.
|
||||||
dr-sync.workspace = true
|
dr-sync.workspace = true
|
||||||
|
# For the panic hook, for `state::set_state_dir` and for
|
||||||
|
# `diagnostics::install`. Android has no XDG directories, so the entry point is
|
||||||
|
# the only place that knows where a crash record or a log file may be written,
|
||||||
|
# and both have to be in place before anything can fail.
|
||||||
|
dr-plat.workspace = true
|
||||||
# Directly, not just through dr-ui: `android_main` takes an `AndroidApp` and
|
# Directly, not just through dr-ui: `android_main` takes an `AndroidApp` and
|
||||||
# calls `slint::android::init`, both of which come from this crate. The backend
|
# calls `slint::android::init`, both of which come from this crate. The backend
|
||||||
# feature comes from dr-ui's target-specific dependency.
|
# feature comes from dr-ui's target-specific dependency.
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
//! * There are no command-line paths. Android's SAF hands out document URIs,
|
//! * There are no command-line paths. Android's SAF hands out document URIs,
|
||||||
//! not filesystem paths (ARCH §6.9), so the viewer opens with an empty
|
//! not filesystem paths (ARCH §6.9), so the viewer opens with an empty
|
||||||
//! browsing list and the library grid is the only way in.
|
//! browsing list and the library grid is the only way in.
|
||||||
//! * Logging goes to logcat. `env_logger` writes to stderr, which Android
|
//! * Logging goes to logcat *and* to a file. `env_logger` writes to stderr,
|
||||||
//! discards.
|
//! which Android discards; logcat replaces it, and a rotating file beside it
|
||||||
|
//! replaces the thing logcat cannot be — a record that outlives the session
|
||||||
|
//! and can be sent to somebody (NFR-OPS-1, [`dr_plat::diagnostics`]).
|
||||||
//!
|
//!
|
||||||
//! The first of those has one exception, and it is the launch `Intent`: a
|
//! The first of those has one exception, and it is the launch `Intent`: a
|
||||||
//! gallery, a file manager or the share sheet can name images to open, and
|
//! gallery, a file manager or the share sheet can name images to open, and
|
||||||
@@ -29,22 +31,78 @@ mod intents;
|
|||||||
/// Android application entry point, called by android-activity's glue.
|
/// Android application entry point, called by android-activity's glue.
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
fn android_main(app: slint::android::AndroidApp) {
|
fn android_main(app: slint::android::AndroidApp) {
|
||||||
android_logger::init_once(
|
// Before the logger, because the logger needs somewhere to write, and
|
||||||
|
// before any `log::` call at all, because records emitted before this line
|
||||||
|
// reach nothing.
|
||||||
|
//
|
||||||
|
// **The external directory, not the internal one, and the difference is
|
||||||
|
// the entire point of the file.** Both are app-private and both survive
|
||||||
|
// backgrounding — the volatile one is the *cache* directory, which is not
|
||||||
|
// in play here. What separates them is retrieval:
|
||||||
|
// `/data/data/<pkg>/files` needs `run-as` against a debuggable build or
|
||||||
|
// root to read, and `/sdcard/Android/data/<pkg>/files` is a plain
|
||||||
|
// `adb pull` from any build, needing no permission since API 19. A log
|
||||||
|
// nobody can get off the device does not do the job NFR-OPS-1 describes.
|
||||||
|
//
|
||||||
|
// The consequence is that anyone holding the tablet can read it, which is
|
||||||
|
// why `dr_plat::diagnostics` redacts at the sink and why configuration —
|
||||||
|
// the account list, and the credential reference beside it — stays on
|
||||||
|
// `internal_data_path` below rather than moving here (NFR-SEC-2).
|
||||||
|
let external = app.external_data_path();
|
||||||
|
if let Some(dir) = external.clone().or_else(|| app.internal_data_path()) {
|
||||||
|
dr_plat::set_state_dir(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `AndroidLogger` rather than `init_once`, so logcat can be *teed* rather
|
||||||
|
// than replaced: `init_once` installs itself as the global logger and
|
||||||
|
// there is only one of those. Everything that reached logcat before this
|
||||||
|
// change still reaches it, at the same level and under the same tag; the
|
||||||
|
// file is strictly additional.
|
||||||
|
let console = android_logger::AndroidLogger::new(
|
||||||
android_logger::Config::default()
|
android_logger::Config::default()
|
||||||
.with_max_level(log::LevelFilter::Info)
|
.with_max_level(log::LevelFilter::Info)
|
||||||
.with_tag("DarkRoom"),
|
.with_tag("DarkRoom"),
|
||||||
);
|
);
|
||||||
|
let logging = dr_plat::diagnostics::install(Box::new(console), log::LevelFilter::Info);
|
||||||
|
|
||||||
// Panics go to stderr, and Android discards stderr. Without this hook a
|
// Panics go to stderr, and Android discards stderr. Without this hook a
|
||||||
// worker thread that panics is invisible: the process survives, the
|
// worker thread that panics is invisible: the process survives, the
|
||||||
// channel it was writing to closes, and the UI reports only that
|
// channel it was writing to closes, and the UI reports only that
|
||||||
// something "failed unexpectedly" with no way to find out what.
|
// something "failed unexpectedly" with no way to find out what.
|
||||||
std::panic::set_hook(Box::new(|info| {
|
//
|
||||||
log::error!("panic: {info}");
|
// This used to be one `log::error!` of the raw panic, which had two
|
||||||
}));
|
// problems: logcat is a ring buffer that is gone by the time a user
|
||||||
|
// reports anything, and the raw message can carry a document URI naming
|
||||||
|
// their library or a credential a library interpolated into an error
|
||||||
|
// (NFR-SEC-2). `dr_plat::crash` writes a redacted record to disk and logs
|
||||||
|
// the redacted form. Nothing uploads it.
|
||||||
|
//
|
||||||
|
// Before `set_state_dir` on purpose: the hook resolves the directory when
|
||||||
|
// it fires, so installing it first covers the startup below rather than
|
||||||
|
// leaving it uncovered.
|
||||||
|
dr_plat::crash::install(env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
|
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
|
// Said in logcat as well as in the file, because the first thing anybody
|
||||||
|
// asked for a log needs is where it is — and on a device that is a path
|
||||||
|
// nobody can guess and a command nobody remembers.
|
||||||
|
match &logging {
|
||||||
|
dr_plat::Installed::ToFile(path) => {
|
||||||
|
log::info!("logging to {}", path.display());
|
||||||
|
if external.is_none() {
|
||||||
|
log::warn!(
|
||||||
|
"no external storage; the log is app-private and needs \
|
||||||
|
`adb shell run-as paris.tourolle.darkroom cat files/darkroom.log` \
|
||||||
|
on a debuggable build"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dr_plat::Installed::ConsoleOnly(why) => {
|
||||||
|
log::warn!("no log file this session, only logcat: {why}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Before anything opens a store: Android has no $HOME and no XDG
|
// Before anything opens a store: Android has no $HOME and no XDG
|
||||||
// directories, so the default guess resolves to a path the app cannot
|
// directories, so the default guess resolves to a path the app cannot
|
||||||
// write. Nothing failed loudly — the session list went to a doomed path, so
|
// write. Nothing failed loudly — the session list went to a doomed path, so
|
||||||
@@ -54,6 +112,10 @@ fn android_main(app: slint::android::AndroidApp) {
|
|||||||
match app.internal_data_path() {
|
match app.internal_data_path() {
|
||||||
Some(dir) => {
|
Some(dir) => {
|
||||||
log::info!("data dir: {}", dir.display());
|
log::info!("data dir: {}", dir.display());
|
||||||
|
// Crash records go beside the account data rather than under it:
|
||||||
|
// both are app-private and neither is a cache, which is the whole
|
||||||
|
// distinction that matters here (see `dr_plat::crash::state_dir`).
|
||||||
|
dr_plat::crash::set_state_dir(dir.join("state"));
|
||||||
dr_sync::account::set_data_dir(dir);
|
dr_sync::account::set_data_dir(dir);
|
||||||
}
|
}
|
||||||
None => log::error!("no internal data path; settings will not persist"),
|
None => log::error!("no internal data path; settings will not persist"),
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ license.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
dr-ui.workspace = true
|
dr-ui.workspace = true
|
||||||
|
# For the panic hook and the log sink, directly rather than through dr-ui:
|
||||||
|
# both have to be installed before `dr_ui::run`, because a panic during startup
|
||||||
|
# is exactly the one they exist to catch and record (NFR-OPS-1, NFR-OPS-2).
|
||||||
|
dr-plat.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
env_logger.workspace = true
|
env_logger.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
|||||||
@@ -4,13 +4,37 @@
|
|||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use dr_plat::diagnostics::Installed;
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(
|
// Built rather than `init`ed, so the same logger can be handed to the
|
||||||
|
// diagnostics tee: `env_logger` keeps writing to stderr exactly as before,
|
||||||
|
// and every record it accepts is also appended to the on-disk log
|
||||||
|
// (NFR-OPS-1). `filter()` is asked afterwards because the environment may
|
||||||
|
// have overridden the default below, and the file must not be quieter than
|
||||||
|
// the terminal.
|
||||||
|
let console = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(
|
||||||
"info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn,rawler=warn",
|
"info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn,rawler=warn",
|
||||||
))
|
))
|
||||||
.init();
|
.build();
|
||||||
|
let level = console.filter();
|
||||||
|
let logging = dr_plat::diagnostics::install(Box::new(console), level);
|
||||||
|
|
||||||
|
// Immediately after the logger and before anything that could fail. Until
|
||||||
|
// now a panic on desktop went to stderr and died with the terminal, which
|
||||||
|
// means every panic a user has ever hit was unreportable: the process
|
||||||
|
// survives (the panicking worker does not), a control goes dead, and there
|
||||||
|
// is nothing on disk to say why. The record is local and stays local —
|
||||||
|
// there is no upload path, by design; see `dr_plat::crash`.
|
||||||
|
dr_plat::crash::install(env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
|
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
|
||||||
|
// First thing in the file, so a user asked for "the log" can find it
|
||||||
|
// without being told a path over the phone.
|
||||||
|
match &logging {
|
||||||
|
Installed::ToFile(path) => log::info!("logging to {}", path.display()),
|
||||||
|
Installed::ConsoleOnly(why) => log::warn!("no log file this session: {why}"),
|
||||||
|
}
|
||||||
|
|
||||||
let paths: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
|
let paths: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
|
||||||
if paths.is_empty() {
|
if paths.is_empty() {
|
||||||
|
|||||||
@@ -1,14 +1,37 @@
|
|||||||
//! TRACES: NFR-ARCH-4 | NFR-R5
|
//! TRACES: NFR-ARCH-4 | NFR-R5 | NFR-R6
|
||||||
//! Catalog errors.
|
//! Catalog errors.
|
||||||
//!
|
//!
|
||||||
//! Typed and attached to the affected subject rather than panicking — a
|
//! Typed and attached to the affected subject rather than panicking — a
|
||||||
//! corrupt row or a failed job marks one image and lets the batch continue.
|
//! corrupt row or a failed job marks one image and lets the batch continue.
|
||||||
|
//!
|
||||||
|
//! # Why `From<rusqlite::Error>` is written by hand
|
||||||
|
//!
|
||||||
|
//! One class of SQLite failure is not about the statement that hit it: when
|
||||||
|
//! the file itself is damaged, *every* query fails, and which one the user
|
||||||
|
//! happened to trigger first says nothing. Before this, corruption reached the
|
||||||
|
//! interface as whatever `Sqlite(...)` the first failing query produced —
|
||||||
|
//! "database disk image is malformed" attached to a thumbnail refresh — and
|
||||||
|
//! there was nowhere to hang a recovery offer.
|
||||||
|
//!
|
||||||
|
//! So the conversion classifies rather than wraps: `SQLITE_CORRUPT` and
|
||||||
|
//! `SQLITE_NOTADB` become [`CatalogError::Corrupt`] wherever they arise, which
|
||||||
|
//! means a background job that trips over the damage reports the same thing
|
||||||
|
//! the startup check does (see [`crate::recovery`]).
|
||||||
|
|
||||||
/// Something went wrong talking to the catalog.
|
/// Something went wrong talking to the catalog.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum CatalogError {
|
pub enum CatalogError {
|
||||||
#[error("sqlite: {0}")]
|
#[error("sqlite: {0}")]
|
||||||
Sqlite(#[from] rusqlite::Error),
|
Sqlite(#[source] rusqlite::Error),
|
||||||
|
|
||||||
|
/// The catalog file is damaged.
|
||||||
|
///
|
||||||
|
/// Its own variant because it is the one error with a *user-facing
|
||||||
|
/// remedy*: restore the NFR-R2 backup, or discard the index and rebuild it
|
||||||
|
/// from sources plus sidecars (NFR-R6, invariant §5.2.4). Every other
|
||||||
|
/// variant here is either a caller's mistake or a fact about one row.
|
||||||
|
#[error("the catalog file is damaged: {detail}")]
|
||||||
|
Corrupt { detail: String },
|
||||||
|
|
||||||
/// The catalog was written by a newer build.
|
/// The catalog was written by a newer build.
|
||||||
///
|
///
|
||||||
@@ -74,3 +97,38 @@ pub enum CatalogError {
|
|||||||
#[error("io: {0}")]
|
#[error("io: {0}")]
|
||||||
Io(String),
|
Io(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<rusqlite::Error> for CatalogError {
|
||||||
|
fn from(e: rusqlite::Error) -> Self {
|
||||||
|
if is_corruption(&e) {
|
||||||
|
// `to_string` rather than keeping the error: the detail is going
|
||||||
|
// into a dialog and into a log line, and the recovery path has no
|
||||||
|
// use for the rusqlite type once it knows the file is damaged.
|
||||||
|
CatalogError::Corrupt {
|
||||||
|
detail: e.to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CatalogError::Sqlite(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a SQLite failure means the *file* is damaged rather than the
|
||||||
|
/// statement wrong.
|
||||||
|
///
|
||||||
|
/// `SQLITE_NOTADB` is included because it is what a truncated or overwritten
|
||||||
|
/// catalog produces — SQLite cannot read the header, so it declines to call it
|
||||||
|
/// a database at all. To a user those are the same accident, and the same two
|
||||||
|
/// offers answer both.
|
||||||
|
///
|
||||||
|
/// Deliberately *not* included: `SQLITE_CANTOPEN` (a missing file, which
|
||||||
|
/// `Connection::open` fixes by creating one), `SQLITE_BUSY`, and
|
||||||
|
/// `SQLITE_IOERR` — a failing disk or a dropped network mount is a different
|
||||||
|
/// problem, and telling the user to rebuild their index would be a wrong
|
||||||
|
/// answer delivered confidently.
|
||||||
|
fn is_corruption(e: &rusqlite::Error) -> bool {
|
||||||
|
matches!(
|
||||||
|
e.sqlite_error_code(),
|
||||||
|
Some(rusqlite::ErrorCode::DatabaseCorrupt) | Some(rusqlite::ErrorCode::NotADatabase)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! TRACES: FR-CAT-5 | FR-CAT-6 | FR-CAT-13 | NFR-R5
|
//! TRACES: FR-CAT-5 | FR-CAT-6 | NFR-R5
|
||||||
//! Keywords: the vocabulary, the assignments, and the edits the UI performs.
|
//! Keywords: the vocabulary, the assignments, and the edits the UI performs.
|
||||||
//!
|
//!
|
||||||
//! The read half of this shipped with the catalog and the write half did not.
|
//! The read half of this shipped with the catalog and the write half did not.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
//! - [`runner`] — the thing that drains it, driven by whoever owns the thread
|
//! - [`runner`] — the thing that drains it, driven by whoever owns the thread
|
||||||
//! - [`trash`] — soft delete to a folder, then permanent delete
|
//! - [`trash`] — soft delete to a folder, then permanent delete
|
||||||
//! - [`merge`] / [`sync`] — cross-device merging of collections and keywords
|
//! - [`merge`] / [`sync`] — cross-device merging of collections and keywords
|
||||||
|
//! - [`recovery`] — backups, and the two offers made when this file is damaged
|
||||||
//!
|
//!
|
||||||
//! # The one thing everything is designed around
|
//! # The one thing everything is designed around
|
||||||
//!
|
//!
|
||||||
@@ -47,6 +48,7 @@ pub mod keywords;
|
|||||||
pub mod merge;
|
pub mod merge;
|
||||||
pub mod query;
|
pub mod query;
|
||||||
pub mod rating;
|
pub mod rating;
|
||||||
|
pub mod recovery;
|
||||||
pub mod runner;
|
pub mod runner;
|
||||||
pub mod scan;
|
pub mod scan;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
@@ -65,6 +67,7 @@ pub use keywords::{Coverage, Keyword, KeywordId, SelectionKeyword};
|
|||||||
pub use merge::MergeReport;
|
pub use merge::MergeReport;
|
||||||
pub use query::{Query, Sort};
|
pub use query::{Query, Sort};
|
||||||
pub use rating::{Judgement, MAX_RATING};
|
pub use rating::{Judgement, MAX_RATING};
|
||||||
|
pub use recovery::Backup;
|
||||||
// Not `runner::Budget`: `cache::Budget` already owns that name here and
|
// Not `runner::Budget`: `cache::Budget` already owns that name here and
|
||||||
// means something else entirely (bytes on disk, not jobs in a slot).
|
// means something else entirely (bytes on disk, not jobs in a slot).
|
||||||
// Callers spell the work budget `runner::Budget`, where it is unambiguous.
|
// Callers spell the work budget `runner::Budget`, where it is unambiguous.
|
||||||
@@ -222,9 +225,23 @@ pub struct Catalog {
|
|||||||
|
|
||||||
impl Catalog {
|
impl Catalog {
|
||||||
/// Open or create a catalog, migrating it forward if needed.
|
/// Open or create a catalog, migrating it forward if needed.
|
||||||
|
///
|
||||||
|
/// Does **not** verify the file — see [`Self::open_verified`], and
|
||||||
|
/// [`recovery`] for why the check is bound to startup rather than to every
|
||||||
|
/// open. Damage this trips over on the way past is still reported as
|
||||||
|
/// [`CatalogError::Corrupt`] rather than as a stray SQLite error.
|
||||||
pub fn open(path: &Path) -> Result<Self, CatalogError> {
|
pub fn open(path: &Path) -> Result<Self, CatalogError> {
|
||||||
let conn = Connection::open(path)?;
|
let conn = Connection::open(path)?;
|
||||||
schema::configure(&conn)?;
|
schema::configure(&conn)?;
|
||||||
|
// NFR-R2, and the reason it is *here*: a migration is the one routine
|
||||||
|
// operation that rewrites table structure, so it is the likeliest way
|
||||||
|
// this file becomes unreadable — and afterwards there is no
|
||||||
|
// pre-migration state left to copy. A failure to take the copy is
|
||||||
|
// logged rather than raised: a full disk must not be the thing that
|
||||||
|
// makes a library unopenable.
|
||||||
|
if let Err(e) = recovery::backup_before_migration(&conn, path) {
|
||||||
|
log::warn!("could not back up before migrating: {e}");
|
||||||
|
}
|
||||||
let from = schema::migrate(&conn)?;
|
let from = schema::migrate(&conn)?;
|
||||||
// A migration adds a column; it cannot know what the value should be
|
// A migration adds a column; it cannot know what the value should be
|
||||||
// for rows that already existed. Backfilling on open is what stops
|
// for rows that already existed. Backfilling on open is what stops
|
||||||
@@ -235,6 +252,26 @@ impl Catalog {
|
|||||||
Ok(Catalog { conn })
|
Ok(Catalog { conn })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: NFR-R6
|
||||||
|
/// Open a catalog, checking the file first.
|
||||||
|
///
|
||||||
|
/// What startup calls. On [`CatalogError::Corrupt`] the caller has a user
|
||||||
|
/// in front of it and must make the two offers [`recovery`] describes,
|
||||||
|
/// rather than reporting a SQLite message on a banner and carrying on into
|
||||||
|
/// a scan that would write into the damage.
|
||||||
|
///
|
||||||
|
/// Checked *before* opening rather than after, because opening runs
|
||||||
|
/// migrations: a damaged catalog that happens to have an intact header
|
||||||
|
/// would otherwise be migrated — rewriting structure on top of structure
|
||||||
|
/// that is already wrong — before anybody asked whether it was sound.
|
||||||
|
pub fn open_verified(path: &Path) -> Result<Self, CatalogError> {
|
||||||
|
// A catalog that is not there yet is not damaged; `open` creates it.
|
||||||
|
if path.is_file() {
|
||||||
|
recovery::check_file(path)?;
|
||||||
|
}
|
||||||
|
Self::open(path)
|
||||||
|
}
|
||||||
|
|
||||||
/// An in-memory catalog, for tests and for a throwaway import preview.
|
/// An in-memory catalog, for tests and for a throwaway import preview.
|
||||||
pub fn in_memory() -> Result<Self, CatalogError> {
|
pub fn in_memory() -> Result<Self, CatalogError> {
|
||||||
let conn = Connection::open_in_memory()?;
|
let conn = Connection::open_in_memory()?;
|
||||||
|
|||||||
@@ -0,0 +1,662 @@
|
|||||||
|
//! TRACES: NFR-R2 | NFR-R6
|
||||||
|
//! What to do once the index is already damaged.
|
||||||
|
//!
|
||||||
|
//! # Why this can be a small module
|
||||||
|
//!
|
||||||
|
//! Because of a property the rest of the catalog was built to keep: the
|
||||||
|
//! catalog is an *index*, not a source of truth (ARCH §6.12, invariant
|
||||||
|
//! §5.2.4). Sidecars beside the images hold the authoritative ratings,
|
||||||
|
//! keywords and edit graphs, for every catalogued image and whether or not a
|
||||||
|
//! remote account exists (FR-CAT-8). So the worst outcome available here is a
|
||||||
|
//! rescan — expensive, but not a loss.
|
||||||
|
//!
|
||||||
|
//! That is the second offer. The first is cheaper and loses nothing at all: a
|
||||||
|
//! backup, restored.
|
||||||
|
//!
|
||||||
|
//! # The one thing a rebuild does not recover
|
||||||
|
//!
|
||||||
|
//! **Collections.** A manual collection is a set of images the user assembled
|
||||||
|
//! by hand and nothing in the filesystem records it (`docs/catalog.md` §8.1) —
|
||||||
|
//! which is the whole reason the catalog file itself syncs. So the two offers
|
||||||
|
//! are not interchangeable, and the interface must not present them as if they
|
||||||
|
//! were: a restore keeps the user's collections, a rebuild does not.
|
||||||
|
//!
|
||||||
|
//! # When the check runs, and when it does not
|
||||||
|
//!
|
||||||
|
//! [`integrity_check`] reads every page of the database. That is affordable
|
||||||
|
//! once, at startup, where a failure has a user in front of it who can answer
|
||||||
|
//! a question — and it is *not* affordable on every [`Catalog::open`], which
|
||||||
|
//! this application does per background task, dozens of times a session. So
|
||||||
|
//! the check is bound to [`Catalog::open_verified`] rather than to `open`,
|
||||||
|
//! and the cheap half of the story — classifying `SQLITE_CORRUPT` and
|
||||||
|
//! `SQLITE_NOTADB` as [`CatalogError::Corrupt`] — happens for free on every
|
||||||
|
//! query through [`crate::error`]'s conversion. A background job that trips
|
||||||
|
//! over the damage first therefore reports the same thing the startup check
|
||||||
|
//! would have.
|
||||||
|
//!
|
||||||
|
//! [`Catalog::open`]: crate::Catalog::open
|
||||||
|
//! [`Catalog::open_verified`]: crate::Catalog::open_verified
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
use crate::error::CatalogError;
|
||||||
|
use crate::schema;
|
||||||
|
|
||||||
|
/// Directory backups live in, relative to the catalog file.
|
||||||
|
///
|
||||||
|
/// Beside the catalog rather than in the cache directory, and that is the
|
||||||
|
/// point of the choice: this is the copy the user falls back on, and a cache
|
||||||
|
/// is a place the operating system is entitled to empty without asking
|
||||||
|
/// (see `library::data_root` for the same reasoning about sidecars).
|
||||||
|
const BACKUP_DIR: &str = "backups";
|
||||||
|
|
||||||
|
/// How many backups are kept.
|
||||||
|
///
|
||||||
|
/// Small on purpose. A backup is a full copy of a catalog that is tens of
|
||||||
|
/// megabytes at 50k images, and the value of the third-oldest one is close to
|
||||||
|
/// zero: corruption is noticed at the next launch, not months later. What the
|
||||||
|
/// depth buys is protection against backing *up* the damage — if a corrupt
|
||||||
|
/// catalog is copied before anyone notices, the generation behind it is still
|
||||||
|
/// clean.
|
||||||
|
pub const KEEP_BACKUPS: usize = 3;
|
||||||
|
|
||||||
|
/// Suffix given to a catalog that has been set aside as damaged.
|
||||||
|
///
|
||||||
|
/// Kept rather than deleted. It costs disk this application would rather not
|
||||||
|
/// spend, and it is still the right call: `.sqlite` files have been recovered
|
||||||
|
/// by hand before, the user has not consented to a deletion, and NFR-R4's
|
||||||
|
/// instinct — never destroy what the user did not ask you to destroy — does
|
||||||
|
/// not stop applying at the catalog's edge.
|
||||||
|
const DAMAGED_SUFFIX: &str = "damaged";
|
||||||
|
|
||||||
|
/// One kept backup.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Backup {
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// UTC seconds at which it was taken, read from the filename rather than
|
||||||
|
/// from the filesystem: a copy, a restore or a sync can rewrite an mtime,
|
||||||
|
/// and then the newest backup is not the one that looks newest.
|
||||||
|
pub taken_at: i64,
|
||||||
|
pub bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where backups for `catalog` are kept.
|
||||||
|
pub fn backup_dir(catalog: &Path) -> PathBuf {
|
||||||
|
catalog
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| Path::new("."))
|
||||||
|
.join(BACKUP_DIR)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check the database this connection is attached to.
|
||||||
|
///
|
||||||
|
/// `quick_check` rather than `integrity_check`: the difference is that
|
||||||
|
/// `quick_check` skips verifying that every index agrees with its table, which
|
||||||
|
/// is the expensive half and the half this application least needs — every
|
||||||
|
/// index here is derivable, and `REINDEX` fixes one without anybody being
|
||||||
|
/// asked a question. What is left still reads every page, and catches the
|
||||||
|
/// damage that matters: torn b-trees, a bad freelist, a truncated file.
|
||||||
|
///
|
||||||
|
/// Returns [`CatalogError::Corrupt`] carrying what SQLite said, so the message
|
||||||
|
/// the user sees is the diagnosis rather than a paraphrase of it.
|
||||||
|
pub fn integrity_check(conn: &Connection) -> Result<(), CatalogError> {
|
||||||
|
// The argument caps how many problems are reported. One is enough: the
|
||||||
|
// answer is the same whether the file has one damaged page or nine
|
||||||
|
// hundred, and an unbounded check on a badly damaged file can run for a
|
||||||
|
// very long time producing a list nobody will read.
|
||||||
|
let mut stmt = conn.prepare("PRAGMA quick_check(1)")?;
|
||||||
|
let rows: Vec<String> = stmt
|
||||||
|
.query_map([], |r| r.get(0))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
// A healthy database answers with the single row "ok".
|
||||||
|
if rows.len() == 1 && rows[0] == "ok" {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(CatalogError::Corrupt {
|
||||||
|
detail: rows.join("; "),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check a catalog file that is not currently open.
|
||||||
|
///
|
||||||
|
/// Used before a restore: a backup is only worth swapping in if it is sound,
|
||||||
|
/// and swapping in a second damaged file — leaving the user with no catalog
|
||||||
|
/// and no offer left — is the failure this exists to prevent.
|
||||||
|
pub fn check_file(path: &Path) -> Result<(), CatalogError> {
|
||||||
|
if !path.is_file() {
|
||||||
|
return Err(CatalogError::Io(format!("{} is missing", path.display())));
|
||||||
|
}
|
||||||
|
// Read-write rather than read-only, which reads oddly for a check. A
|
||||||
|
// backup carries the WAL journal mode in its header because it was copied
|
||||||
|
// page-for-page from a WAL database, and SQLite cannot open one read-only
|
||||||
|
// without a shared-memory file it is then not allowed to create. Nothing
|
||||||
|
// here writes; the connection is opened, read, and dropped.
|
||||||
|
let conn = Connection::open(path)?;
|
||||||
|
integrity_check(&conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take a backup of the open catalog.
|
||||||
|
///
|
||||||
|
/// Returns the file written. Older generations beyond [`KEEP_BACKUPS`] are
|
||||||
|
/// pruned, newest kept.
|
||||||
|
///
|
||||||
|
/// Goes through `crate::sync::copy_to` — SQLite's own backup API after a
|
||||||
|
/// TRUNCATE checkpoint — rather than copying the file. A WAL database is not
|
||||||
|
/// one file, and `fs::copy` of the main file alone would silently back up a
|
||||||
|
/// state that is older than the catalog and possibly torn, which is the one
|
||||||
|
/// failure mode a backup cannot afford.
|
||||||
|
pub fn backup(conn: &Connection, catalog: &Path) -> Result<PathBuf, CatalogError> {
|
||||||
|
let dir = backup_dir(catalog);
|
||||||
|
std::fs::create_dir_all(&dir)
|
||||||
|
.map_err(|e| CatalogError::Io(format!("creating {}: {e}", dir.display())))?;
|
||||||
|
|
||||||
|
let dest = dir.join(format!("catalog-{}.sqlite", now()));
|
||||||
|
// A second backup within the same second would otherwise land on the first
|
||||||
|
// one's name. Rare, and only reachable from tests and a retry, but the
|
||||||
|
// result would be a half-overwritten backup rather than two.
|
||||||
|
if dest.exists() {
|
||||||
|
std::fs::remove_file(&dest)
|
||||||
|
.map_err(|e| CatalogError::Io(format!("replacing {}: {e}", dest.display())))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dropped immediately: the copy is complete when `copy_to` returns, and
|
||||||
|
// holding the connection open would leave a `-wal` beside a file whose
|
||||||
|
// whole purpose is to be a single self-contained artefact.
|
||||||
|
drop(crate::sync::copy_to(conn, &dest)?);
|
||||||
|
|
||||||
|
prune(catalog);
|
||||||
|
Ok(dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Back up before a migration, if there is anything to back up.
|
||||||
|
///
|
||||||
|
/// Called from [`Catalog::open`](crate::Catalog::open) between `configure` and
|
||||||
|
/// `migrate`. NFR-R2 asks for this and the reasoning is narrower than "backups
|
||||||
|
/// are prudent": a migration is the one routine operation that rewrites table
|
||||||
|
/// structure, so it is the likeliest way a catalog becomes unreadable, and it
|
||||||
|
/// is the one moment where the pre-change state is still on disk to be copied.
|
||||||
|
/// Afterwards there is nothing left to take a copy *of*.
|
||||||
|
///
|
||||||
|
/// A no-op in the two cases where it would cost without buying anything: a
|
||||||
|
/// catalog already at [`schema::SCHEMA_VERSION`], and a brand-new file at
|
||||||
|
/// version 0 with no tables in it yet.
|
||||||
|
pub fn backup_before_migration(conn: &Connection, catalog: &Path) -> Result<(), CatalogError> {
|
||||||
|
let from: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
||||||
|
if from == 0 || from >= schema::SCHEMA_VERSION {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let path = backup(conn, catalog)?;
|
||||||
|
log::info!(
|
||||||
|
"backed up catalog at v{from} to {} before migrating to v{}",
|
||||||
|
path.display(),
|
||||||
|
schema::SCHEMA_VERSION
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The backups available for `catalog`, newest first.
|
||||||
|
///
|
||||||
|
/// Never fails: an unreadable or absent backup directory means there are no
|
||||||
|
/// backups, which is a fact about the offer to make rather than an error to
|
||||||
|
/// report on top of the corruption the user is already looking at.
|
||||||
|
pub fn backups(catalog: &Path) -> Vec<Backup> {
|
||||||
|
let dir = backup_dir(catalog);
|
||||||
|
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut out: Vec<Backup> = entries
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|e| {
|
||||||
|
let path = e.path();
|
||||||
|
let taken_at = timestamp_of(&path)?;
|
||||||
|
let bytes = e.metadata().ok()?.len();
|
||||||
|
Some(Backup {
|
||||||
|
path,
|
||||||
|
taken_at,
|
||||||
|
bytes,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
out.sort_by_key(|b| std::cmp::Reverse(b.taken_at));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put a backup back in place of the damaged catalog.
|
||||||
|
///
|
||||||
|
/// **Every connection to `catalog` must be closed first.** This replaces the
|
||||||
|
/// file underneath anything still holding it open, which on a live connection
|
||||||
|
/// is how a *second* corrupt catalog gets made.
|
||||||
|
///
|
||||||
|
/// The order is deliberate:
|
||||||
|
///
|
||||||
|
/// 1. The backup is checked. A restore that installs a second damaged file
|
||||||
|
/// leaves the user with nothing to try next.
|
||||||
|
/// 2. The damaged catalog is renamed aside, and its `-wal` and `-shm` are
|
||||||
|
/// **deleted**. This is the step that is easy to leave out and fatal to
|
||||||
|
/// leave out: a journal belonging to the old file, sitting beside the new
|
||||||
|
/// one under the same name, is replayed into it on the next open. That is
|
||||||
|
/// not a restore, it is a fresh corruption with the evidence gone.
|
||||||
|
/// 3. The backup is *copied* into place, not moved, so a failure here can be
|
||||||
|
/// retried against the same backup.
|
||||||
|
pub fn restore(catalog: &Path, backup: &Path) -> Result<(), CatalogError> {
|
||||||
|
check_file(backup)?;
|
||||||
|
set_aside(catalog)?;
|
||||||
|
std::fs::copy(backup, catalog).map_err(|e| {
|
||||||
|
CatalogError::Io(format!(
|
||||||
|
"restoring {} from {}: {e}",
|
||||||
|
catalog.display(),
|
||||||
|
backup.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
log::info!(
|
||||||
|
"restored {} from backup {}",
|
||||||
|
catalog.display(),
|
||||||
|
backup.display()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move a damaged catalog out of the way so the next open builds a fresh one.
|
||||||
|
///
|
||||||
|
/// This is the rebuild path (NFR-R6's second offer) and also the first half of
|
||||||
|
/// a [`restore`]. Nothing else is needed to rebuild: the next
|
||||||
|
/// [`Catalog::open`](crate::Catalog::open) creates an empty catalog at the
|
||||||
|
/// current schema, and the ordinary scan repopulates it from sources and
|
||||||
|
/// sidecars — which is precisely invariant §5.2.4 being spent rather than
|
||||||
|
/// merely asserted.
|
||||||
|
///
|
||||||
|
/// Returns where the damaged file was put, or `None` if there was no catalog
|
||||||
|
/// to move — a caller may be recovering from a file SQLite could not open
|
||||||
|
/// because it was never created.
|
||||||
|
pub fn set_aside(catalog: &Path) -> Result<Option<PathBuf>, CatalogError> {
|
||||||
|
let moved = if catalog.exists() {
|
||||||
|
let dest = with_suffix(catalog, DAMAGED_SUFFIX);
|
||||||
|
// An earlier damaged copy is replaced rather than accumulating: two of
|
||||||
|
// these is two full-size catalogs on the user's disk, and the older
|
||||||
|
// one has already been superseded by a recovery the user completed.
|
||||||
|
let _ = std::fs::remove_file(&dest);
|
||||||
|
// The rename first, so that a failure here leaves the journals with
|
||||||
|
// the file they belong to rather than orphaned beside a catalog that
|
||||||
|
// is still in use.
|
||||||
|
std::fs::rename(catalog, &dest).map_err(|e| {
|
||||||
|
CatalogError::Io(format!(
|
||||||
|
"setting aside {} as {}: {e}",
|
||||||
|
catalog.display(),
|
||||||
|
dest.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
log::warn!(
|
||||||
|
"catalog {} was damaged; kept as {}",
|
||||||
|
catalog.display(),
|
||||||
|
dest.display()
|
||||||
|
);
|
||||||
|
Some(dest)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Then the journals, whether or not there was a catalog to move: a `-wal`
|
||||||
|
// orphaned beside a missing database is replayed into whatever takes that
|
||||||
|
// name next, which would not be a restore but a fresh corruption with the
|
||||||
|
// evidence gone.
|
||||||
|
for sidecar in journals(catalog) {
|
||||||
|
if let Err(e) = std::fs::remove_file(&sidecar) {
|
||||||
|
if e.kind() != std::io::ErrorKind::NotFound {
|
||||||
|
return Err(CatalogError::Io(format!(
|
||||||
|
"removing stale journal {}: {e}",
|
||||||
|
sidecar.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(moved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete backups beyond [`KEEP_BACKUPS`].
|
||||||
|
///
|
||||||
|
/// Best-effort and silent about individual failures: failing to delete an old
|
||||||
|
/// backup is not a reason to fail the new one, which is already written.
|
||||||
|
fn prune(catalog: &Path) {
|
||||||
|
for old in backups(catalog).into_iter().skip(KEEP_BACKUPS) {
|
||||||
|
if let Err(e) = std::fs::remove_file(&old.path) {
|
||||||
|
log::warn!("could not prune backup {}: {e}", old.path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The WAL and shared-memory files SQLite keeps beside a database.
|
||||||
|
fn journals(catalog: &Path) -> [PathBuf; 2] {
|
||||||
|
[with_suffix(catalog, "wal"), with_suffix(catalog, "shm")]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `catalog.sqlite` plus `-suffix`, the way SQLite names its own sidecars.
|
||||||
|
///
|
||||||
|
/// Appended to the whole filename rather than replacing the extension, so
|
||||||
|
/// `catalog.sqlite-wal` is what SQLite would look for and `catalog.sqlite-
|
||||||
|
/// damaged` sorts next to the catalog it came from.
|
||||||
|
fn with_suffix(catalog: &Path, suffix: &str) -> PathBuf {
|
||||||
|
let mut s = catalog.as_os_str().to_os_string();
|
||||||
|
s.push("-");
|
||||||
|
s.push(suffix);
|
||||||
|
PathBuf::from(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the timestamp out of a backup's filename, or `None` if this is not one.
|
||||||
|
///
|
||||||
|
/// Doubles as the filter that keeps [`backups`] from offering the user
|
||||||
|
/// something that is not a catalog — a stray file in the directory, or a `-wal`
|
||||||
|
/// left by a crash mid-backup.
|
||||||
|
fn timestamp_of(path: &Path) -> Option<i64> {
|
||||||
|
let name = path.file_name()?.to_str()?;
|
||||||
|
name.strip_prefix("catalog-")?
|
||||||
|
.strip_suffix(".sqlite")?
|
||||||
|
.parse()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seconds since the epoch, or 0 if the clock is before it.
|
||||||
|
fn now() -> i64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::Catalog;
|
||||||
|
use std::io::{Seek, SeekFrom, Write};
|
||||||
|
|
||||||
|
/// A scratch directory that cleans up with the test.
|
||||||
|
fn tempdir(tag: &str) -> PathBuf {
|
||||||
|
let base = std::env::temp_dir().join(format!(
|
||||||
|
"dr-recovery-{tag}-{}-{:?}",
|
||||||
|
std::process::id(),
|
||||||
|
std::thread::current().id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
base
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A catalog on disk with enough rows to span several pages, closed.
|
||||||
|
///
|
||||||
|
/// Closed matters: WAL means the rows are in `catalog.sqlite-wal` until
|
||||||
|
/// something checkpoints, and a test that corrupted the main file while
|
||||||
|
/// the data was still in the journal would be corrupting empty space.
|
||||||
|
fn fixture(path: &Path, images: i64) {
|
||||||
|
let cat = Catalog::open(path).unwrap();
|
||||||
|
let c = cat.connection();
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO collections(uuid, name, kind, created, revision, modified)
|
||||||
|
VALUES ('u1', 'Iceland', 0, 0, 1, 1)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for i in 1..=images {
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO images(id, root_id, source_ref, added_at)
|
||||||
|
VALUES (?1, 1, ?2, 0)",
|
||||||
|
rusqlite::params![i, format!("DCIM/IMG_{i:05}.CR3")],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
crate::sync::checkpoint(c).unwrap();
|
||||||
|
drop(cat);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scribble over everything past the first two pages.
|
||||||
|
///
|
||||||
|
/// Past them rather than over them so that page 1 — the header and the
|
||||||
|
/// schema — survives: this produces a file SQLite is willing to open and
|
||||||
|
/// then finds damaged, which is the case `quick_check` exists for. Wiping
|
||||||
|
/// the header instead produces `SQLITE_NOTADB` at the first pragma, which
|
||||||
|
/// is a different branch and has its own test.
|
||||||
|
fn corrupt(path: &Path) {
|
||||||
|
let mut f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
|
||||||
|
let len = f.metadata().unwrap().len();
|
||||||
|
assert!(
|
||||||
|
len > 8192,
|
||||||
|
"fixture is only {len} bytes; corrupting past page 2 would be a no-op"
|
||||||
|
);
|
||||||
|
let junk = vec![0x5a_u8; (len - 8192) as usize];
|
||||||
|
f.seek(SeekFrom::Start(8192)).unwrap();
|
||||||
|
f.write_all(&junk).unwrap();
|
||||||
|
f.sync_all().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_healthy_catalog_passes() {
|
||||||
|
let cat = Catalog::in_memory().unwrap();
|
||||||
|
integrity_check(cat.connection()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_corrupt_catalog_is_reported_as_corrupt_not_as_sqlite() {
|
||||||
|
// The whole point of the variant: this used to arrive as whatever
|
||||||
|
// rusqlite error the first failing query produced, with nowhere to
|
||||||
|
// hang a recovery offer.
|
||||||
|
let dir = tempdir("detect");
|
||||||
|
let path = dir.join("catalog.sqlite");
|
||||||
|
fixture(&path, 500);
|
||||||
|
corrupt(&path);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
Catalog::open_verified(&path),
|
||||||
|
Err(CatalogError::Corrupt { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_that_is_not_a_database_is_also_corrupt() {
|
||||||
|
// A truncated or overwritten catalog never reaches `quick_check`: the
|
||||||
|
// first pragma fails with SQLITE_NOTADB. Same accident to the user,
|
||||||
|
// same two offers, so it must classify the same way.
|
||||||
|
let dir = tempdir("notadb");
|
||||||
|
let path = dir.join("catalog.sqlite");
|
||||||
|
std::fs::write(&path, b"this is not a catalog, it is a text file\n").unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
Catalog::open(&path),
|
||||||
|
Err(CatalogError::Corrupt { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restoring_a_backup_recovers_the_collections_a_rebuild_would_lose() {
|
||||||
|
// The first NFR-R6 branch, asserted on the thing that distinguishes it
|
||||||
|
// from the second: a collection exists nowhere but the catalog, so it
|
||||||
|
// is the evidence that the *contents* came back and not merely a
|
||||||
|
// readable file (docs/catalog.md §8.1).
|
||||||
|
let dir = tempdir("restore");
|
||||||
|
let path = dir.join("catalog.sqlite");
|
||||||
|
fixture(&path, 500);
|
||||||
|
|
||||||
|
{
|
||||||
|
let cat = Catalog::open(&path).unwrap();
|
||||||
|
backup(cat.connection(), &path).unwrap();
|
||||||
|
}
|
||||||
|
corrupt(&path);
|
||||||
|
assert!(matches!(
|
||||||
|
Catalog::open_verified(&path),
|
||||||
|
Err(CatalogError::Corrupt { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let newest = backups(&path).into_iter().next().expect("a backup exists");
|
||||||
|
restore(&path, &newest.path).unwrap();
|
||||||
|
|
||||||
|
let cat = Catalog::open_verified(&path).unwrap();
|
||||||
|
let name: String = cat
|
||||||
|
.connection()
|
||||||
|
.query_row("SELECT name FROM collections", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(name, "Iceland");
|
||||||
|
let images: i64 = cat
|
||||||
|
.connection()
|
||||||
|
.query_row("SELECT count(*) FROM images", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(images, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_damaged_backup_is_refused_rather_than_installed() {
|
||||||
|
let dir = tempdir("badbackup");
|
||||||
|
let path = dir.join("catalog.sqlite");
|
||||||
|
fixture(&path, 500);
|
||||||
|
{
|
||||||
|
let cat = Catalog::open(&path).unwrap();
|
||||||
|
backup(cat.connection(), &path).unwrap();
|
||||||
|
}
|
||||||
|
let newest = backups(&path).into_iter().next().unwrap();
|
||||||
|
corrupt(&newest.path);
|
||||||
|
corrupt(&path);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
restore(&path, &newest.path),
|
||||||
|
Err(CatalogError::Corrupt { .. })
|
||||||
|
));
|
||||||
|
// And the damaged catalog is still where it was, so the second offer
|
||||||
|
// is still available.
|
||||||
|
assert!(path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setting_aside_leaves_a_fresh_catalog_to_rebuild_into() {
|
||||||
|
// The second NFR-R6 branch. What makes it a rebuild rather than a data
|
||||||
|
// loss is invariant §5.2.4, which lives outside this crate — what is
|
||||||
|
// testable here is that the damaged file is out of the way, kept, and
|
||||||
|
// that the next open succeeds on an empty catalog at the current
|
||||||
|
// schema, which is what a scan then fills.
|
||||||
|
let dir = tempdir("rebuild");
|
||||||
|
let path = dir.join("catalog.sqlite");
|
||||||
|
fixture(&path, 500);
|
||||||
|
corrupt(&path);
|
||||||
|
|
||||||
|
let kept = set_aside(&path).unwrap().expect("the catalog was there");
|
||||||
|
assert!(kept.exists(), "the damaged catalog was deleted, not kept");
|
||||||
|
assert!(!path.exists());
|
||||||
|
|
||||||
|
let cat = Catalog::open_verified(&path).unwrap();
|
||||||
|
let images: i64 = cat
|
||||||
|
.connection()
|
||||||
|
.query_row("SELECT count(*) FROM images", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(images, 0);
|
||||||
|
let v: i64 = cat
|
||||||
|
.connection()
|
||||||
|
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(v, schema::SCHEMA_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stale_journal_does_not_follow_the_catalog_into_recovery() {
|
||||||
|
// The step that is easy to omit: a `-wal` belonging to the damaged
|
||||||
|
// file is replayed into whatever takes its name next.
|
||||||
|
let dir = tempdir("journal");
|
||||||
|
let path = dir.join("catalog.sqlite");
|
||||||
|
fixture(&path, 500);
|
||||||
|
std::fs::write(with_suffix(&path, "wal"), b"stale").unwrap();
|
||||||
|
|
||||||
|
set_aside(&path).unwrap();
|
||||||
|
assert!(!with_suffix(&path, "wal").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_migration_is_backed_up_before_it_runs() {
|
||||||
|
// NFR-R2's second clause, against a real v1 catalog rather than a
|
||||||
|
// faked version number: the point is not that *a* file appears but
|
||||||
|
// that it holds the state from before the migration, which is the only
|
||||||
|
// state that is any use if the migration is what breaks it.
|
||||||
|
let dir = tempdir("premigrate");
|
||||||
|
let path = dir.join("catalog.sqlite");
|
||||||
|
{
|
||||||
|
let c = Connection::open(&path).unwrap();
|
||||||
|
schema::configure(&c).unwrap();
|
||||||
|
// `v1_for_attached` names the schema it targets, and "main" is a
|
||||||
|
// schema like any other — so this is the real v1, without needing
|
||||||
|
// `V1` itself to become visible outside its module.
|
||||||
|
c.execute_batch(&schema::v1_for_attached("main")).unwrap();
|
||||||
|
c.pragma_update(None, "user_version", 1).unwrap();
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
crate::sync::checkpoint(&c).unwrap();
|
||||||
|
}
|
||||||
|
assert!(backups(&path).is_empty());
|
||||||
|
|
||||||
|
Catalog::open(&path).unwrap();
|
||||||
|
|
||||||
|
let taken = backups(&path);
|
||||||
|
assert_eq!(taken.len(), 1, "no backup was taken before the migration");
|
||||||
|
check_file(&taken[0].path).unwrap();
|
||||||
|
let kept = Connection::open(&taken[0].path).unwrap();
|
||||||
|
let v: i64 = kept
|
||||||
|
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(v, 1, "the backup was taken after the migration, not before");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opening_an_up_to_date_catalog_takes_no_backup() {
|
||||||
|
// Or every background task that opens the catalog would copy it.
|
||||||
|
let dir = tempdir("nobackup");
|
||||||
|
let path = dir.join("catalog.sqlite");
|
||||||
|
fixture(&path, 10);
|
||||||
|
Catalog::open(&path).unwrap();
|
||||||
|
assert!(backups(&path).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_newest_generations_are_kept() {
|
||||||
|
let dir = tempdir("prune");
|
||||||
|
let path = dir.join("catalog.sqlite");
|
||||||
|
fixture(&path, 10);
|
||||||
|
let cat = Catalog::open(&path).unwrap();
|
||||||
|
|
||||||
|
// Written by hand rather than by calling `backup` in a loop: the
|
||||||
|
// filename carries whole seconds, so real calls would collide.
|
||||||
|
std::fs::create_dir_all(backup_dir(&path)).unwrap();
|
||||||
|
for t in 1..=KEEP_BACKUPS as i64 + 2 {
|
||||||
|
drop(
|
||||||
|
crate::sync::copy_to(
|
||||||
|
cat.connection(),
|
||||||
|
&backup_dir(&path).join(format!("catalog-{t}.sqlite")),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
prune(&path);
|
||||||
|
|
||||||
|
let kept = backups(&path);
|
||||||
|
assert_eq!(kept.len(), KEEP_BACKUPS);
|
||||||
|
// Newest first, and the newest is the highest timestamp.
|
||||||
|
assert_eq!(kept[0].taken_at, KEEP_BACKUPS as i64 + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stray_file_in_the_backup_directory_is_not_offered_as_one() {
|
||||||
|
let dir = tempdir("stray");
|
||||||
|
let path = dir.join("catalog.sqlite");
|
||||||
|
fixture(&path, 10);
|
||||||
|
std::fs::create_dir_all(backup_dir(&path)).unwrap();
|
||||||
|
std::fs::write(backup_dir(&path).join("notes.txt"), b"hello").unwrap();
|
||||||
|
std::fs::write(backup_dir(&path).join("catalog-7.sqlite-wal"), b"x").unwrap();
|
||||||
|
|
||||||
|
assert!(backups(&path).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,21 @@ pub fn checkpoint(conn: &Connection) -> Result<(), CatalogError> {
|
|||||||
/// coherent even with writers active. Callers should still prefer a quiet
|
/// coherent even with writers active. Callers should still prefer a quiet
|
||||||
/// moment — this competes with background jobs for the write lock.
|
/// moment — this competes with background jobs for the write lock.
|
||||||
pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), CatalogError> {
|
pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), CatalogError> {
|
||||||
|
let out = copy_to(conn, dest)?;
|
||||||
|
strip_face_crops(&out)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checkpoint, then copy the whole database to `dest`, and hand back the
|
||||||
|
/// connection to the copy.
|
||||||
|
///
|
||||||
|
/// Split out from [`snapshot_for_upload`] because [`crate::recovery`] wants
|
||||||
|
/// exactly this and none of what follows it there: an NFR-R2 backup is the
|
||||||
|
/// file the user may have to *live on*, so it keeps the face crops that an
|
||||||
|
/// upload strips. Sharing the copy rather than reimplementing it is what keeps
|
||||||
|
/// the WAL discipline in one place — a backup taken with `fs::copy` would be
|
||||||
|
/// the torn snapshot this module's header exists to warn about.
|
||||||
|
pub(crate) fn copy_to(conn: &Connection, dest: &Path) -> Result<Connection, CatalogError> {
|
||||||
checkpoint(conn)?;
|
checkpoint(conn)?;
|
||||||
|
|
||||||
let mut out = Connection::open(dest)?;
|
let mut out = Connection::open(dest)?;
|
||||||
@@ -63,8 +78,7 @@ pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), Catalog
|
|||||||
backup.run_to_completion(i32::MAX, std::time::Duration::ZERO, None)?;
|
backup.run_to_completion(i32::MAX, std::time::Duration::ZERO, None)?;
|
||||||
drop(backup);
|
drop(backup);
|
||||||
|
|
||||||
strip_face_crops(&out)?;
|
Ok(out)
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop the stored face crops from a snapshot before it is uploaded.
|
/// Drop the stored face crops from a snapshot before it is uploaded.
|
||||||
|
|||||||
@@ -1727,6 +1727,24 @@ mod tests {
|
|||||||
assert!(!restored.is_neutral());
|
assert!(!restored.is_neutral());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-PLG-8
|
||||||
|
/// FR-PLG-8's preservation clause, and only that clause.
|
||||||
|
///
|
||||||
|
/// The requirement opens by saying the sidecar "already preserves lines it
|
||||||
|
/// does not understand verbatim and writes them back untouched", and that
|
||||||
|
/// the property "is now load-bearing and shall be treated as such". This is
|
||||||
|
/// the test that makes it load-bearing rather than incidental.
|
||||||
|
///
|
||||||
|
/// **It is weaker than the requirement's stated acceptance criterion**,
|
||||||
|
/// which is that the re-saved file be *byte-identical* to the original.
|
||||||
|
/// This asserts `contains`. The two halves of that criterion exist
|
||||||
|
/// separately — `writing_the_same_state_twice_is_byte_identical` proves
|
||||||
|
/// byte identity for content this build understands, and this proves
|
||||||
|
/// survival for content it does not — and nothing yet joins them into the
|
||||||
|
/// one claim FR-PLG-8 makes. Everything else the requirement asks for
|
||||||
|
/// (aggregated alerts, the export gate, rendering without rather than
|
||||||
|
/// guessing) is unbuilt, which is why the tag is on these two tests and
|
||||||
|
/// not on the module.
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unknown_operation_survives_a_round_trip() {
|
fn an_unknown_operation_survives_a_round_trip() {
|
||||||
// The data-loss case that matters: a device running an older build
|
// The data-loss case that matters: a device running an older build
|
||||||
@@ -1742,6 +1760,13 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-PLG-8
|
||||||
|
/// The other half of preservation: kept in the file, kept out of the edit.
|
||||||
|
///
|
||||||
|
/// "Render without, never render a guess" is a separate clause, and this is
|
||||||
|
/// not it — that one is about a *recognised* operation whose plugin is
|
||||||
|
/// missing. This is the narrower claim that an unparsed line cannot reach
|
||||||
|
/// the graph by accident, which is what makes preserving it safe.
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unknown_operation_does_not_reach_the_graph() {
|
fn an_unknown_operation_does_not_reach_the_graph() {
|
||||||
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
|
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ pub struct CollectionId(pub u64);
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
pub struct FolderId(pub u64);
|
pub struct FolderId(pub u64);
|
||||||
|
|
||||||
/// TRACES: FR-CAT-1a | FR-PLAT-AND-1
|
/// TRACES: FR-CAT-1a
|
||||||
/// An opaque, re-resolvable reference to source image data.
|
/// An opaque, re-resolvable reference to source image data.
|
||||||
///
|
///
|
||||||
/// **Never a filesystem path.** Android's Storage Access Framework provides no
|
/// **Never a filesystem path.** Android's Storage Access Framework provides no
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
"_readme": [
|
||||||
|
"The committed numbers for DarkRoom's benchmark suite (docs/requirements.md §8).",
|
||||||
|
"Produced and checked by `cargo run --release -p dr-bench`; docs/benchmarks.md explains each metric.",
|
||||||
|
"",
|
||||||
|
"Two gates, and they are not the same gate. `budget` is the requirement's own threshold and never moves.",
|
||||||
|
"`recorded` is what the reference desktop last measured, and a run that drifts past `tolerance` beyond it",
|
||||||
|
"fails the build even while still inside the budget — which is how most performance rot actually arrives.",
|
||||||
|
"",
|
||||||
|
"`recorded` is null on every metric because nobody has run the suite yet. That is deliberate: writing",
|
||||||
|
"plausible-looking figures here would make every later comparison a comparison against a guess. Run",
|
||||||
|
"`cargo run --release -p dr-bench -- record --reference` on the reference desktop and commit the diff.",
|
||||||
|
"Until then the budget gate works and the regression gate says, in the report, that it cannot.",
|
||||||
|
"",
|
||||||
|
"`machine_sensitive` says whether a budget is a statement about a machine as much as about the code.",
|
||||||
|
"Those budgets are asserted only under --reference: §8 names the reference desktop, and a two-core CI",
|
||||||
|
"container cannot speak to a target written for twenty-four threads. Asserting one there would produce a",
|
||||||
|
"red gate everybody learns to ignore, which is the trap core/dr-gpu/tests/frame_budget.rs already avoids.",
|
||||||
|
"",
|
||||||
|
"Several metrics carry a requirement ID with a qualifier. Read those literally. NFR-P7's budget here is",
|
||||||
|
"checked against the encode half of an export only — no GPU render is in the figure — so it can fail the",
|
||||||
|
"requirement and cannot pass it, and no TRACES tag claims otherwise. NFR-P8 has no budget at all yet,",
|
||||||
|
"because nobody has decided how much of its 500 MB belongs to the catalog layer; this records the number",
|
||||||
|
"that decision needs."
|
||||||
|
],
|
||||||
|
"tolerance": 0.15,
|
||||||
|
"recorded_on": null,
|
||||||
|
"recorded_at_unix": null,
|
||||||
|
"fixture": null,
|
||||||
|
"metrics": {
|
||||||
|
"catalog_filtered_ms": {
|
||||||
|
"requirement": "FR-CAT-6",
|
||||||
|
"what": "Count plus first window under a rating filter, which compiles to a correlated subquery over versions.",
|
||||||
|
"unit": "ms",
|
||||||
|
"direction": "lower_is_better",
|
||||||
|
"machine_sensitive": true,
|
||||||
|
"budget": null,
|
||||||
|
"recorded": null
|
||||||
|
},
|
||||||
|
"catalog_idle_rss_mb": {
|
||||||
|
"requirement": "NFR-P8 (the catalog layer's share only — no toolkit, no adapter, no decode cache)",
|
||||||
|
"what": "Resident memory of a process that opened the 50k catalog and scrolled ten thousand rows.",
|
||||||
|
"unit": "MB",
|
||||||
|
"direction": "lower_is_better",
|
||||||
|
"machine_sensitive": false,
|
||||||
|
"budget": null,
|
||||||
|
"recorded": null
|
||||||
|
},
|
||||||
|
"catalog_open_ms": {
|
||||||
|
"requirement": "NFR-P1",
|
||||||
|
"what": "Catalog::open plus the count, first window and timeline the grid cannot paint without.",
|
||||||
|
"unit": "ms",
|
||||||
|
"direction": "lower_is_better",
|
||||||
|
"machine_sensitive": false,
|
||||||
|
"budget": 2000.0,
|
||||||
|
"recorded": null
|
||||||
|
},
|
||||||
|
"catalog_open_warm_ms": {
|
||||||
|
"requirement": "NFR-P1",
|
||||||
|
"what": "The same four calls on a second connection, with SQLite's page cache already warm.",
|
||||||
|
"unit": "ms",
|
||||||
|
"direction": "lower_is_better",
|
||||||
|
"machine_sensitive": false,
|
||||||
|
"budget": 2000.0,
|
||||||
|
"recorded": null
|
||||||
|
},
|
||||||
|
"catalog_window_p99_ms": {
|
||||||
|
"requirement": "FR-CAT-4",
|
||||||
|
"what": "One 400-row grid window at a random offset, p99 of one hundred.",
|
||||||
|
"unit": "ms",
|
||||||
|
"direction": "lower_is_better",
|
||||||
|
"machine_sensitive": true,
|
||||||
|
"budget": null,
|
||||||
|
"recorded": null
|
||||||
|
},
|
||||||
|
"export_24mp_long_edge_2048_ms": {
|
||||||
|
"requirement": "FR-EXP-3",
|
||||||
|
"what": "The web export: resample a 24 MP frame to a 2048 px long edge, sharpen, encode. p99 of five.",
|
||||||
|
"unit": "ms",
|
||||||
|
"direction": "lower_is_better",
|
||||||
|
"machine_sensitive": true,
|
||||||
|
"budget": null,
|
||||||
|
"recorded": null
|
||||||
|
},
|
||||||
|
"export_24mp_original_ms": {
|
||||||
|
"requirement": "NFR-P7 (the encode half only — the GPU render is not in this figure)",
|
||||||
|
"what": "Resample, output-sharpen and JPEG-encode a 24 MP frame at source size. p99 of five.",
|
||||||
|
"unit": "ms",
|
||||||
|
"direction": "lower_is_better",
|
||||||
|
"machine_sensitive": true,
|
||||||
|
"budget": 2000.0,
|
||||||
|
"recorded": null
|
||||||
|
},
|
||||||
|
"thumbnail_per_image_p99_ms": {
|
||||||
|
"requirement": "NFR-P3",
|
||||||
|
"what": "One thumbnail on its own lane: decode the preview, downscale, orient, encode. p99.",
|
||||||
|
"unit": "ms",
|
||||||
|
"direction": "lower_is_better",
|
||||||
|
"machine_sensitive": true,
|
||||||
|
"budget": null,
|
||||||
|
"recorded": null
|
||||||
|
},
|
||||||
|
"thumbnail_throughput_ips": {
|
||||||
|
"requirement": "NFR-P3",
|
||||||
|
"what": "Whole-sweep throughput: 1200 thumbnails through the sweep's chunk-and-lane shape, wall clock.",
|
||||||
|
"unit": "img/s",
|
||||||
|
"direction": "higher_is_better",
|
||||||
|
"machine_sensitive": true,
|
||||||
|
"budget": 100.0,
|
||||||
|
"recorded": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
# The benchmark suite
|
||||||
|
|
||||||
|
**Status:** Built, not yet recorded · 2026-08-30
|
||||||
|
**Companion to:** [requirements.md](requirements.md) §4.1 (performance targets) · §8 (verification)
|
||||||
|
**Instrument:** [`tools/bench`](../tools/bench) — `cargo run --release -p dr-bench -- check`
|
||||||
|
**Committed numbers:** [`bench-baseline.json`](bench-baseline.json)
|
||||||
|
**GPU half:** [`core/dr-gpu/tests/frame_budget.rs`](../core/dr-gpu/tests/frame_budget.rs) ·
|
||||||
|
[frame-budget.md](frame-budget.md)
|
||||||
|
|
||||||
|
§8 has said since it was written that performance is verified by *"an automated
|
||||||
|
benchmark suite against a synthetic 50k catalog, run per-commit … A regression
|
||||||
|
beyond stated tolerance fails the build."* Until this suite there was none. No
|
||||||
|
`benches/`, no `[[bench]]`, no criterion, no fixture — and ten performance
|
||||||
|
requirements that could therefore be neither passed nor failed, five of them
|
||||||
|
carrying a `TRACES:` tag regardless.
|
||||||
|
|
||||||
|
This file is what the suite covers, what it deliberately does not, and how to
|
||||||
|
read a failure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The state of it, first
|
||||||
|
|
||||||
|
**No numbers have been recorded yet.** Every `recorded` field in
|
||||||
|
[`bench-baseline.json`](bench-baseline.json) is `null`, on purpose: writing
|
||||||
|
plausible-looking figures into a baseline would make every later comparison a
|
||||||
|
comparison against a guess, and the first real regression would be invisible.
|
||||||
|
|
||||||
|
To record them, on the reference desktop:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run --release -p dr-bench -- record --reference
|
||||||
|
```
|
||||||
|
|
||||||
|
and commit the diff. Until that happens the **budget** gate works — a catalog
|
||||||
|
that takes three seconds to open fails the build today — and the **regression**
|
||||||
|
gate reports that it has nothing to compare against, rather than pretending.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What it measures
|
||||||
|
|
||||||
|
| Metric | Requirement | Gated? |
|
||||||
|
|---|---|---|
|
||||||
|
| `catalog_open_ms` | **NFR-P1**, and R2's second sentence | Yes, everywhere — budget 2000 ms |
|
||||||
|
| `catalog_open_warm_ms` | NFR-P1, page cache warm | Yes, everywhere — budget 2000 ms |
|
||||||
|
| `catalog_window_p99_ms` | FR-CAT-4 | Regression only |
|
||||||
|
| `catalog_filtered_ms` | FR-CAT-6 | Regression only |
|
||||||
|
| `thumbnail_throughput_ips` | **NFR-P3** | Budget 100 img/s, on the reference desktop |
|
||||||
|
| `thumbnail_per_image_p99_ms` | NFR-P3 | Regression only |
|
||||||
|
| `export_24mp_original_ms` | NFR-P7, **encode half only** | One-sided: can fail it, cannot pass it |
|
||||||
|
| `export_24mp_long_edge_2048_ms` | FR-EXP-3 | Regression only |
|
||||||
|
| `catalog_idle_rss_mb` | NFR-P8, **catalog layer only** | Regression only — see below |
|
||||||
|
|
||||||
|
Two of those rows carry a qualifier, and the qualifiers are the point.
|
||||||
|
|
||||||
|
### Requirements this can now pass *or* fail
|
||||||
|
|
||||||
|
**NFR-P1 — catalog open under 2 s.** The measured span is the four things the
|
||||||
|
library view cannot paint without: `Catalog::open` (which connects, migrates and
|
||||||
|
**backfills**, and the backfill is three passes over the images table on every
|
||||||
|
open), `count`, the first 400-row `window`, and the monthly `timeline`. Tagged
|
||||||
|
`TRACES: NFR-P1` in [`tools/bench/src/catalog_open.rs`](../tools/bench/src/catalog_open.rs),
|
||||||
|
because a build that breaks it fails this gate.
|
||||||
|
|
||||||
|
**NFR-P3 — ≥ 100 images per second on the embedded preview path.** The
|
||||||
|
per-image work is exactly what `spawn_thumbnail_sweep` does — `decode_jpeg`,
|
||||||
|
`Preview::downscale_to`, `Preview::apply_orientation`, `encode_rgba`,
|
||||||
|
`ThumbStore::put` — arranged in the same shape: chunks of 96, lanes owning
|
||||||
|
disjoint slices, and the single thread that owns the store writing the finished
|
||||||
|
chunk. Tagged `TRACES: NFR-P3` in
|
||||||
|
[`tools/bench/src/thumbnails.rs`](../tools/bench/src/thumbnails.rs).
|
||||||
|
|
||||||
|
### Requirements this can only half-answer, and is not tagged for
|
||||||
|
|
||||||
|
**NFR-P7 — 24 MP export under 2 s, full chain.** The full chain is decode,
|
||||||
|
demosaic, a full-resolution GPU render, a read-back, then resize, sharpen and
|
||||||
|
encode. Only the last three run without an adapter. So the figure here is a
|
||||||
|
**lower bound** on the requirement: exceeding 2 s in the encode alone violates
|
||||||
|
NFR-P7 no matter how fast the render is, and coming in under it proves nothing.
|
||||||
|
The budget is gated on that basis and there is no `TRACES: NFR-P7` anywhere in
|
||||||
|
`tools/bench`.
|
||||||
|
|
||||||
|
**NFR-P8 — idle memory under 500 MB.** The probe is a fresh process holding the
|
||||||
|
catalog and nothing else: no Slint, no wgpu device, no font stack, no decode
|
||||||
|
cache. Its RSS is the catalog layer's *share* of that 500 MB, not the figure the
|
||||||
|
requirement is about. It carries no budget for a reason given below.
|
||||||
|
|
||||||
|
### Requirements out of scope, listed so their absence reads as a decision
|
||||||
|
|
||||||
|
NFR-P2 (grid scroll at 60 fps), P4 (open in develop), P5 (slider to visible),
|
||||||
|
P6 (pan/zoom), P9 (UI-executor blocking), P10 (touch response), P11 (layout
|
||||||
|
transition), P12 (warm shader setup), P13 (next image in culling), P14 (focus
|
||||||
|
peaking), P15 (drawn mask stroke). Every one of them needs a frame-timing probe
|
||||||
|
inside a running Slint application, a GPU adapter, or both. None is faked here.
|
||||||
|
|
||||||
|
The GPU half of the story that *does* exist is
|
||||||
|
[frame-budget.md](frame-budget.md) and its guard test, which asserts FR-DSP-3
|
||||||
|
and skips itself where there is no adapter. `.gitea/workflows/benchmark.yml`
|
||||||
|
runs it as its own job for exactly that reason.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The fixture
|
||||||
|
|
||||||
|
Fifty thousand rows over a pool of twelve real image files. Rows are cheap and
|
||||||
|
pixels are not: everything the catalog half touches is rows and is therefore
|
||||||
|
exact at full scale, and everything the pixel half touches is one file at a time
|
||||||
|
and does not care how many rows point at it. The result is ~14 MB on disk
|
||||||
|
instead of ~2 TB, and neither half is flattered by that.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Rows | 50,000 images, 50,000 default versions, 400 folders, one root |
|
||||||
|
| Capture times | Twelve years from a fixed epoch, so the timeline has ~144 monthly buckets |
|
||||||
|
| Sources | 12 synthesised JPEGs at 1620 × 1080 — the size `dr-decode` records a CR2 carrying in IFD2 |
|
||||||
|
| Seed | 20260829, in [`tools/bench/src/main.rs`](../tools/bench/src/main.rs) |
|
||||||
|
| Location | `$DR_BENCH_DIR`, else the system temporary directory |
|
||||||
|
|
||||||
|
It is reproducible from the seed, and a `stamp.json` beside it records what it
|
||||||
|
was built from — seed, row count, source count, preview size, and `dr-catalog`'s
|
||||||
|
schema version. A mismatch rebuilds rather than silently measuring a different
|
||||||
|
workload than the baseline describes.
|
||||||
|
|
||||||
|
Two honest limits on it:
|
||||||
|
|
||||||
|
- **The page cache is warm.** The fixture was written by this suite or by an
|
||||||
|
earlier run of it, so neither the catalog open nor the thumbnail sweep pays
|
||||||
|
for a cold disk. On the reference desktop's NVMe a genuinely cold read of a
|
||||||
|
14 MB catalog is tens of milliseconds; on spinning rust it is not.
|
||||||
|
- **The sources are synthetic.** A coarse gradient with a fine dither, which is
|
||||||
|
what `frame_budget.rs` synthesises for the same reason — a flat frame lets the
|
||||||
|
memory system serve every sample from one cache line and flatters a box
|
||||||
|
filter, and pure noise defeats the entropy coder in the other direction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Two gates, and how to read a failure
|
||||||
|
|
||||||
|
**Budget.** The requirement's own threshold. It does not move. Failing it means
|
||||||
|
a requirement is violated.
|
||||||
|
|
||||||
|
**Regression.** More than 15% worse than the last recorded figure *on the same
|
||||||
|
machine, against the same fixture*. Failing it means the code got slower while
|
||||||
|
still inside the requirement — which is how most performance rot actually
|
||||||
|
arrives, never over the line, always a little worse, until one day the line is
|
||||||
|
crossed by a change that was not the cause.
|
||||||
|
|
||||||
|
A metric declares whether its budget is `machine_sensitive`. Those are asserted
|
||||||
|
only under `--reference`, and reported everywhere else. §8 names *"the reference
|
||||||
|
desktop"*, not CI, and it is right to: a container with two cores cannot speak
|
||||||
|
to a throughput target written for twenty-four threads, and asserting one there
|
||||||
|
would produce exactly what `core/dr-gpu/tests/frame_budget.rs` refused to
|
||||||
|
produce — *"a red suite that everyone learns to ignore"*. Catalog open is not
|
||||||
|
machine-sensitive: 2 s against an expected figure two orders of magnitude
|
||||||
|
smaller is a threshold any machine can be held to.
|
||||||
|
|
||||||
|
Exit codes: `0` everything passed, `1` a gate failed, `2` the harness itself
|
||||||
|
could not run. Distinguished so a CI log that says "failed" does not leave
|
||||||
|
anyone guessing whether the code got slower or the fixture would not build.
|
||||||
|
|
||||||
|
**Release, always.** The workspace builds its own crates at `opt-level = 0` in
|
||||||
|
dev, and every figure here is dominated by this workspace's own code — the JPEG
|
||||||
|
decode, the box filter, the resample, the sharpen. A debug run measures rustc's
|
||||||
|
shadow. The report says which profile it was built in on its second line.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NFR-P8, and the question §4.1 asks
|
||||||
|
|
||||||
|
§4.1 says NFR-P8 *"must state whether it measures RSS inclusive or exclusive of
|
||||||
|
GPU allocations, and whether it holds after SQLite's page cache warms on a 50k
|
||||||
|
catalog."* Both halves have an answer.
|
||||||
|
|
||||||
|
**On the page cache: warm.** The probe runs the count, the timeline and
|
||||||
|
twenty-five windows before it reads its counters, so SQLite's cache holds the
|
||||||
|
b-tree pages a scroll touches. That is the right side to err on — a figure taken
|
||||||
|
before the cache warms would understate a steady-state library.
|
||||||
|
|
||||||
|
**On GPU memory: RSS is exclusive of device-local allocations, and cannot be
|
||||||
|
made otherwise.** A Vulkan allocation in a device-local heap never enters the
|
||||||
|
process's address space, so nothing under `/proc/self/status` can see it. What
|
||||||
|
*does* land in RSS is the host-visible side — staging buffers, mapped upload
|
||||||
|
rings, the read-back `AdjustPass` performs on export — plus the driver's own
|
||||||
|
resident pages.
|
||||||
|
|
||||||
|
So "idle memory < 500 MB" is two questions wearing one number, and a build
|
||||||
|
holding 400 MB of RSS and 3 GB of textures would pass it.
|
||||||
|
|
||||||
|
**Recommendation: NFR-P8 should be restated as two figures** — host RSS
|
||||||
|
exclusive of device-local memory, and a separate VRAM ceiling read from the
|
||||||
|
adapter — because the second is the one that decides whether the application
|
||||||
|
survives beside a browser on an 8 GB card, and nothing in this repository
|
||||||
|
measures it today.
|
||||||
|
|
||||||
|
**And a decision is outstanding.** `catalog_idle_rss_mb` carries no budget
|
||||||
|
because nobody has decided how much of the 500 MB belongs to the catalog layer
|
||||||
|
and how much to everything above it. The suite records the number so that
|
||||||
|
decision can be taken against a measurement rather than an estimate. When it is
|
||||||
|
taken, put the figure in `budget` and the metric becomes a gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is not measured, and would be worth adding
|
||||||
|
|
||||||
|
- **The UI's own open.** `ui/dr-ui/src/library.rs` does not call
|
||||||
|
`Catalog::count` or `Catalog::window`; it issues its own SQL against the same
|
||||||
|
tables, with a `VISIBLE` predicate and a burst-folding clause. `dr-bench`
|
||||||
|
cannot see those without depending on `dr-ui`, which would drag Slint into a
|
||||||
|
job that has no display. **Falsifiable end:** when the grid's queries move
|
||||||
|
down into `dr-catalog` — which is where SQL over catalog tables belongs —
|
||||||
|
`catalog_open_ms` becomes the whole of the application's open and this caveat
|
||||||
|
can be deleted rather than argued about.
|
||||||
|
- **The remote sweep.** `spawn_thumbnail_sweep`'s wall clock against a real
|
||||||
|
server is latency, not CPU, and is what FR-NC-3's design is judged by. It
|
||||||
|
needs a server and belongs in a different kind of test.
|
||||||
|
- **A cold disk.** See the fixture's limits above.
|
||||||
|
- **Android.** §4.1 states a second column of targets and §8 asks for
|
||||||
|
"periodically on the named reference Android devices". Nothing here runs on a
|
||||||
|
device. Spike S10 is the piece of work that would start it.
|
||||||
|
- **Everything with a frame in it.** See the out-of-scope list above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Measure and print. Judges nothing.
|
||||||
|
cargo run --release -p dr-bench -- run
|
||||||
|
|
||||||
|
# Measure and gate. What CI runs.
|
||||||
|
cargo run --release -p dr-bench -- check
|
||||||
|
|
||||||
|
# The same, with machine-sensitive budgets asserted too.
|
||||||
|
cargo run --release -p dr-bench -- check --reference
|
||||||
|
|
||||||
|
# Rewrite bench-baseline.json from this run, and commit the diff.
|
||||||
|
cargo run --release -p dr-bench -- record --reference
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful flags: `--fixture <dir>` (or `$DR_BENCH_DIR`) to put the synthetic
|
||||||
|
catalog somewhere specific, `--lanes <n>` to pin the sweep's parallelism, and
|
||||||
|
`--thumbnails <n>` to lengthen or shorten the throughput row.
|
||||||
+24
-15
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it.
|
Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it.
|
||||||
|
|
||||||
15 gestures, in 2 places.
|
16 gestures, in 2 places.
|
||||||
|
|
||||||
## People
|
## People
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ Grouping over-merges on siblings, on parents and children, and on the same perso
|
|||||||
- **Touch** — Tick to confirm it, cross to reject it
|
- **Touch** — Tick to confirm it, cross to reject it
|
||||||
- **Pointer** — Tick to confirm it, cross to reject it
|
- **Pointer** — Tick to confirm it, cross to reject it
|
||||||
|
|
||||||
A face is either the system's guess or the user's judgement, and the two are never conflated. A rejection is remembered, so the face is not suggested for that person again.
|
A face is either the system's guess or the user's judgement, and the two are never conflated. A rejection is remembered, so the face is not suggested for that person again. The gesture note above is the whole label: a tick and a cross are only "confirm" and "reject" to someone who can see the suggestion they sit beside, and `IconButton`'s fallback would announce them as "check" and "cross" — two icon names that say nothing about which person is being ruled on.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/identity.slint:150`</sub>
|
<sub>`ui/dr-ui/ui/identity.slint:150`</sub>
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ A face is either the system's guess or the user's judgement, and the two are nev
|
|||||||
|
|
||||||
This is the point of having identified anybody. Without it the screen is a filing cabinet with no drawer handles.
|
This is the point of having identified anybody. Without it the screen is a filing cabinet with no drawer handles.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/identity.slint:545`</sub>
|
<sub>`ui/dr-ui/ui/identity.slint:552`</sub>
|
||||||
|
|
||||||
### Change how faces are grouped
|
### Change how faces are grouped
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ This is the point of having identified anybody. Without it the screen is a filin
|
|||||||
|
|
||||||
The right match confidence is a property of your library, not of the model. "What would this do?" answers for this library without writing anything; names, confirmations and the groups you have set aside are kept whatever the dials say.
|
The right match confidence is a property of your library, not of the model. "What would this do?" answers for this library without writing anything; names, confirmations and the groups you have set aside are kept whatever the dials say.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/identity.slint:582`</sub>
|
<sub>`ui/dr-ui/ui/identity.slint:589`</sub>
|
||||||
|
|
||||||
## Library grid
|
## Library grid
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ The right match confidence is a property of your library, not of the model. "Wha
|
|||||||
|
|
||||||
Touch has no ctrl, so without a mode there is no way to select a second photograph — the first tap would open it. The hold is the fast way in and the button is the one that can be found.
|
Touch has no ctrl, so without a mode there is no way to select a second photograph — the first tap would open it. The hold is the fast way in and the button is the one that can be found.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:1182`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:1184`</sub>
|
||||||
|
|
||||||
### Add or remove one photograph
|
### Add or remove one photograph
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ Touch has no ctrl, so without a mode there is no way to select a second photogra
|
|||||||
|
|
||||||
While selecting, a tap never opens. That is the whole point of the mode: one meaning per gesture at a time. Press Done to get tap-to-open back.
|
While selecting, a tap never opens. That is the whole point of the mode: one meaning per gesture at a time. Press Done to get tap-to-open back.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:1191`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:1193`</sub>
|
||||||
|
|
||||||
### Leave selecting
|
### Leave selecting
|
||||||
|
|
||||||
@@ -71,7 +71,16 @@ While selecting, a tap never opens. That is the whole point of the mode: one mea
|
|||||||
- **Pointer** — Press Done in the header
|
- **Pointer** — Press Done in the header
|
||||||
- **Keyboard** — Escape
|
- **Keyboard** — Escape
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:1199`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:1201`</sub>
|
||||||
|
|
||||||
|
### Pick a photograph up to drag it
|
||||||
|
|
||||||
|
- **Touch** — Press and hold it until a ring opens around it, then drag
|
||||||
|
- **Pointer** — Drag it
|
||||||
|
|
||||||
|
A finger on a photograph might be starting a scroll, and for the first half-second the grid assumes it is. Holding says otherwise, and the ring is the grid saying it heard — from there the drag cannot be lost to a scroll. A mouse never waits: the cursor is precise enough that a sideways drag is unambiguous from the first pixel.
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/library.slint:1231`</sub>
|
||||||
|
|
||||||
### Select a range
|
### Select a range
|
||||||
|
|
||||||
@@ -80,7 +89,7 @@ While selecting, a tap never opens. That is the whole point of the mode: one mea
|
|||||||
|
|
||||||
This replaced a double tap, which had no visible state and could take forty photographs by accident. The run is resolved by the catalog rather than by what is on screen, so the grid can scroll between the two taps — the ranges that hurt on a tablet are longer than a screenful, which is exactly where a finger sweep runs out.
|
This replaced a double tap, which had no visible state and could take forty photographs by accident. The run is resolved by the catalog rather than by what is on screen, so the grid can scroll between the two taps — the ranges that hurt on a tablet are longer than a screenful, which is exactly where a finger sweep runs out.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:1260`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:1296`</sub>
|
||||||
|
|
||||||
### Find photographs with two people in them
|
### Find photographs with two people in them
|
||||||
|
|
||||||
@@ -89,7 +98,7 @@ This replaced a double tap, which had no visible state and could take forty phot
|
|||||||
|
|
||||||
"Any of them" is a union and "all of them" is an intersection. The tray is where both terms and the choice between them live, because a filter belongs on the filter bar.
|
"Any of them" is a union and "all of them" is an intersection. The tray is where both terms and the choice between them live, because a filter belongs on the filter bar.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:1928`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:1978`</sub>
|
||||||
|
|
||||||
### Resize the thumbnails
|
### Resize the thumbnails
|
||||||
|
|
||||||
@@ -98,7 +107,7 @@ This replaced a double tap, which had no visible state and could take forty phot
|
|||||||
|
|
||||||
There is no wheel on a tablet, so without the pinch the cell size could only be changed by a control a finger cannot reach.
|
There is no wheel on a tablet, so without the pinch the cell size could only be changed by a control a finger cannot reach.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:2554`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:2620`</sub>
|
||||||
|
|
||||||
### File photographs in a collection
|
### File photographs in a collection
|
||||||
|
|
||||||
@@ -107,7 +116,7 @@ There is no wheel on a tablet, so without the pinch the cell size could only be
|
|||||||
|
|
||||||
The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture.
|
The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:2717`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:2783`</sub>
|
||||||
|
|
||||||
### Open a photograph
|
### Open a photograph
|
||||||
|
|
||||||
@@ -116,7 +125,7 @@ The selection is what the drag carries, which is why selecting several is worth
|
|||||||
|
|
||||||
A tap opens; a tap that *moved* does not. Travel is what separates a deliberate tap from a hand brushing past, and it is the only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush.
|
A tap opens; a tap that *moved* does not. Travel is what separates a deliberate tap from a hand brushing past, and it is the only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:2972`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:3051`</sub>
|
||||||
|
|
||||||
### Rate a photograph without opening it
|
### Rate a photograph without opening it
|
||||||
|
|
||||||
@@ -126,7 +135,7 @@ A tap opens; a tap that *moved* does not. Travel is what separates a deliberate
|
|||||||
|
|
||||||
A star has to take the press without it also reaching the cell, or every rating throws the user into develop.
|
A star has to take the press without it also reaching the cell, or every rating throws the user into develop.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:3084`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:3171`</sub>
|
||||||
|
|
||||||
### Drop the selection but keep selecting
|
### Drop the selection but keep selecting
|
||||||
|
|
||||||
@@ -135,7 +144,7 @@ A star has to take the press without it also reaching the cell, or every rating
|
|||||||
|
|
||||||
Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away.
|
Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:3747`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:3879`</sub>
|
||||||
|
|
||||||
### Select everything the grid is showing
|
### Select everything the grid is showing
|
||||||
|
|
||||||
@@ -144,4 +153,4 @@ Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the ne
|
|||||||
|
|
||||||
A scoped grid of two hundred frames is two hundred taps otherwise, and "all of them, except those three" is a far more common shape than the taps it took to say it.
|
A scoped grid of two hundred frames is two hundred taps otherwise, and "all of them, except those three" is a far more common shape than the taps it took to say it.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:3764`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:3896`</sub>
|
||||||
|
|||||||
+96
-46
@@ -177,13 +177,18 @@ The Android app is not a stub — it builds an APK, runs the whole application,
|
|||||||
models, and has been measured on a tablet ([faces.md §12.1](faces.md),
|
models, and has been measured on a tablet ([faces.md §12.1](faces.md),
|
||||||
[technical-debt.md TD-1](technical-debt.md)). What is missing is the platform contract around it.
|
[technical-debt.md TD-1](technical-debt.md)). What is missing is the platform contract around it.
|
||||||
|
|
||||||
**FR-PLAT-AND-1 is tagged and should not be relied on.** The requirement demands that library access
|
**FR-PLAT-AND-1 is untagged, and what it was tagged for was intent rather than code.**
|
||||||
be obtained *exclusively* through the Storage Access Framework. There is no SAF code: no
|
The requirement demands that library access be obtained *exclusively* through the Storage Access
|
||||||
`ACTION_OPEN_DOCUMENT_TREE`, no `takePersistableUriPermission`, no `DocumentsContract`. The two tags
|
Framework. There is no SAF code: no `ACTION_OPEN_DOCUMENT_TREE`, no `takePersistableUriPermission`,
|
||||||
rest on a `SourceRef::Document` variant that nothing constructs and a volumes helper, which is the
|
no `DocumentsContract`. Its two tags rested on a `SourceRef::Document` variant constructed only
|
||||||
"plumbing a future feature would use" case [CONTRIBUTING.md](../CONTRIBUTING.md) and
|
inside `#[cfg(test)]` — `LocalStorage::open` refuses it, and the test that proves so is named
|
||||||
[code-health.md CH-4](code-health.md) both warn about. Android reaches a library through a Nextcloud
|
`a_reference_of_the_wrong_kind_is_refused_rather_than_guessed_at` — and on
|
||||||
account or a folder, over paths, like the desktop.
|
`dr_plat::imports_supported`, which *returns false on Android* and whose own documentation says it
|
||||||
|
"stops being false when a SAF implementation lands". The second tag documented the absence of the
|
||||||
|
thing it was counted as evidence for. Both have been removed; this is the "plumbing a future feature
|
||||||
|
would use" case [CONTRIBUTING.md](../CONTRIBUTING.md) and [code-health.md CH-4](code-health.md) both
|
||||||
|
warn about. Android reaches a library through a Nextcloud account or a folder, over paths, like the
|
||||||
|
desktop.
|
||||||
|
|
||||||
That has a consequence for the rest of the cluster: **FR-PLAT-AND-2** — detecting the loss of a
|
That has a consequence for the rest of the cluster: **FR-PLAT-AND-2** — detecting the loss of a
|
||||||
granted tree permission and marking images offline rather than deleting rows — cannot be built until
|
granted tree permission and marking images offline rather than deleting rows — cannot be built until
|
||||||
@@ -220,10 +225,20 @@ the host is invisible to a sandboxed process as well. The fix is an `ashpd` dire
|
|||||||
`LocalStorage::grant`, not a change to the manifest. No Flatpak has been built here —
|
`LocalStorage::grant`, not a change to the manifest. No Flatpak has been built here —
|
||||||
`flatpak-builder` is not installed — so the permission set is reasoned, not observed.
|
`flatpak-builder` is not installed — so the permission set is reasoned, not observed.
|
||||||
|
|
||||||
**NFR-COMPAT-2 — distribution channels.** Unstated, and this is the requirement that makes the
|
**NFR-COMPAT-2 — distribution channels. Stated, which is all this requirement asks.** The paragraph
|
||||||
others binding: §4.8 observes that the decision to publish on Play is what turns SAF from a
|
above cites [distribution.md](distribution.md) and it is the same document that answers this: §1
|
||||||
preference into a constraint. Spike S11, the Play permissions dry-run that would settle it, has not
|
names five channels and their state — Arch source package and Flatpak in tree, AppImage a v1 channel
|
||||||
run. Related, NFR-COMPAT-1's baseline is real but scattered — API 28/36 live in the Android
|
whose recipe is not written, F-Droid a v1 channel not yet submitted, and Play explicitly **not** v1.
|
||||||
|
The requirement is to *state* the channels, and they are stated, including the deferral.
|
||||||
|
|
||||||
|
What remains is the coupling the requirement points at rather than the statement it demands. §4.8
|
||||||
|
observes that publishing on Play is what turns SAF from a preference into a constraint, and
|
||||||
|
distribution.md §6 argues the coupling runs the other way for this project — F-Droid asks nothing
|
||||||
|
that ARCH §6.9 does not already require. Spike S11, the Play permissions dry-run, has not run, and
|
||||||
|
until it does that argument is reasoned rather than confirmed. Two of the five channels also exist
|
||||||
|
as decisions rather than as recipes, and no Flatpak has been built here at all.
|
||||||
|
|
||||||
|
Related, NFR-COMPAT-1's baseline is real but scattered — API 28/36 live in the Android
|
||||||
Dockerfile and are checked in CI against the built ELF, which is good — while the items the
|
Dockerfile and are checked in CI against the built ELF, which is good — while the items the
|
||||||
requirement singles out are missing: whether `shaderFloat16` and 16-bit storage are required (the
|
requirement singles out are missing: whether `shaderFloat16` and 16-bit storage are required (the
|
||||||
one it flags as jeopardising R1), minimum RAM, minimum desktop Mesa, and a named reference device
|
one it flags as jeopardising R1), minimum RAM, minimum desktop Mesa, and a named reference device
|
||||||
@@ -255,9 +270,22 @@ on one control — the parameter slider in `adjust.slint` — and nothing is set
|
|||||||
all. Everything else in eighteen Slint files is unnamed to AT-SPI and TalkBack. The requirement's own
|
all. Everything else in eighteen Slint files is unnamed to AT-SPI and TalkBack. The requirement's own
|
||||||
caveat, that Slint's Android accessibility needs verifying, is spike S13, which has not run.
|
caveat, that Slint's Android accessibility needs verifying, is spike S13, which has not run.
|
||||||
|
|
||||||
**NFR-A11Y-3 — Colour-independent status.** No compliance work found. This is cheap to satisfy while
|
**NFR-A11Y-3 — Colour-independent status.** Built where a control exists, and now tagged: the
|
||||||
a control is being written and expensive to retrofit across forty of them, which is an argument for
|
clipping readout pairs a marker that appears or disappears with a figure in words, the rating strip
|
||||||
doing it as part of the NFR-A11Y-2 pass rather than after it.
|
is a solid star against an outline in an achromatic palette, the pick/reject mark is a tick against
|
||||||
|
a cross, and the focus-peaking colour chips say "Red" and "Cyan" rather than showing swatches. Each
|
||||||
|
of those already carried the reasoning in a comment naming this requirement and simply had no
|
||||||
|
`TRACES` line.
|
||||||
|
|
||||||
|
Two caveats, because the tag now says more than the evidence does. **Only the clipping clause has a
|
||||||
|
test** — `a_clipping_figure_distinguishes_none_from_nearly_none`, which pins `<0.1%` apart from `0%`
|
||||||
|
so the figure cannot contradict the lit marker beside it. The three Slint components are
|
||||||
|
inspected-and-argued, not asserted, and nothing would fail if a future edit made a star differ only
|
||||||
|
in tint. **And the requirement's first named example has no interface at all**: catalog colour
|
||||||
|
labels are a nullable `label INTEGER` column on the versions table and are set and shown nowhere, so
|
||||||
|
the clause about them is untestable rather than satisfied. That clause closes when the label UI is
|
||||||
|
built, not before, and it should be built with a shape from the outset — which is the same argument
|
||||||
|
as below, for doing this alongside NFR-A11Y-2 rather than after it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -277,39 +305,54 @@ well optimised — ETag pruning under FR-NC-4 turns an unchanged 50k library int
|
|||||||
gap is narrower than it reads. It is the *first* build against a large remote library that pays, and
|
gap is narrower than it reads. It is the *first* build against a large remote library that pays, and
|
||||||
that is the moment a new user meets.
|
that is the moment a new user meets.
|
||||||
|
|
||||||
**FR-CAT-13 — XMP interoperability, tagged and not met.** Read and write standard XMP sidecars. The
|
**FR-CAT-13 — XMP interoperability, untagged and not met.** Read and write standard XMP sidecars.
|
||||||
single tag sits on `keywords.rs`, which stores keywords; no XMP is parsed or written anywhere in the
|
Its single tag sat on `keywords.rs`, which stores keywords in the catalog and mentions `dc:subject`
|
||||||
tree, and `dr-export`'s metadata module says so about its own half ("neither is read by `dr-decode`
|
in a comment about what a keyword's text is *for*; no XMP is parsed or written anywhere in the tree,
|
||||||
today"). Listed here rather than silently, because a tag makes a gap invisible and this one is
|
and `dr-export`'s metadata module says so about its own half ("neither is read by `dr-decode`
|
||||||
load-bearing for interoperating with the editors FR-CAT-14 imports from.
|
today"). The tag has been removed — `dr-preset-xmp` is not the counter-example it looks like, being
|
||||||
|
a reader of Lightroom *presets* under FR-DEV-6, which is a different file and a different purpose.
|
||||||
|
Listed here rather than silently, because a tag makes a gap invisible and this one is load-bearing
|
||||||
|
for interoperating with the editors FR-CAT-14 imports from.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. The performance targets are unverified, not unmet
|
## 8. The performance targets are half-verified, and the half that is left is the hard one
|
||||||
|
|
||||||
Eleven of the fifteen §4.1 targets carry no tag: NFR-P2, -P3, -P4, -P6, -P7, -P8, -P10, -P11, -P12,
|
§8 and §4.1 both require the same thing in the same words: an automated benchmark suite against a
|
||||||
-P14, -P15. That is the uninteresting part of this section.
|
synthetic 50k catalog, run per commit, where **"a regression beyond a stated tolerance is a build
|
||||||
|
failure, not a notification."** For most of this project's life it did not exist — no `benches/`, no
|
||||||
|
criterion, no synthetic catalog, and three CI workflows that between them measured nothing.
|
||||||
|
|
||||||
The interesting part is that §8 and §4.1 both require the same thing, in the same words, and it does
|
**It exists now, for everything that does not need a frame.** [`tools/bench`](../tools/bench) builds
|
||||||
not exist: an automated benchmark suite against a synthetic 50k catalog, run per commit, where **"a
|
a deterministic 50,000-row catalog over a pool of a dozen real files, measures against it, and fails
|
||||||
regression beyond a stated tolerance is a build failure, not a notification."** There is no
|
the build on a violated budget or a drift past tolerance;
|
||||||
`benches/` directory in the workspace, no criterion dependency, and no synthetic catalog. The three
|
[`.gitea/workflows/benchmark.yml`](../.gitea/workflows/benchmark.yml) runs it on every push, and
|
||||||
CI workflows run `cargo fmt --check`, clippy, `cargo test --workspace`, a release build, an Android
|
[benchmarks.md](benchmarks.md) is the account of what it does and does not cover. **NFR-P1** and
|
||||||
cross-build and a layering check. None of them measures anything, so there is no baseline to
|
**NFR-P3** are now genuinely gated, and R2's "catalog opens in under 2s" clause with them.
|
||||||
regress against and no tolerance to exceed.
|
|
||||||
|
|
||||||
What does exist is narrower and genuinely good: `dr-gpu/examples/frame_budget` is a real instrument,
|
Three qualifications, all of them stated in the harness itself rather than only here:
|
||||||
its results are committed in [frame-budget.md](frame-budget.md) with the machine and profile named,
|
|
||||||
and TD-4's before-and-after was measured with it. But it is run by hand — frame-budget.md's own
|
|
||||||
instruction is "rerun and diff this file" — and the guard version that does live in CI skips itself
|
|
||||||
where there is no GPU adapter, which the workflow notes is the normal case on a runner, while
|
|
||||||
asserting its CPU half only when `debug_assertions` is off, which a dev-profile `cargo test` is not.
|
|
||||||
In CI it therefore asserts approximately nothing.
|
|
||||||
|
|
||||||
**The claim to take from this is precise.** Nothing here says the performance targets are missed.
|
- **The numbers have not been recorded yet.** Every `recorded` field in
|
||||||
Several are plausibly met. It says that if one were broken tomorrow, nobody would find out — which
|
[bench-baseline.json](bench-baseline.json) is `null`, deliberately: a fabricated baseline is worse
|
||||||
is the failure mode §8 was written to prevent, and the reason it belongs in this document rather
|
than none. Until `dr-bench record --reference` is run on the reference desktop and committed, the
|
||||||
than in a backlog.
|
budget gate works and the regression gate does not.
|
||||||
|
- **NFR-P7 and NFR-P8 are half-measured and are not tagged.** The export row covers the encode half
|
||||||
|
of the chain and no GPU render, so it can fail the requirement and cannot pass it. The memory row
|
||||||
|
covers a process holding the catalog and nothing else — no toolkit, no adapter — so it is the
|
||||||
|
catalog layer's share of the 500 MB rather than the figure NFR-P8 is about. Neither carries a
|
||||||
|
`TRACES:` tag, which is the point.
|
||||||
|
- **NFR-P8 needs a decision, not more code.** How much of its 500 MB belongs below the UI is
|
||||||
|
unstated, and until somebody says, the metric can record but not judge. [benchmarks.md](benchmarks.md)
|
||||||
|
also answers the question §4.1 raises about GPU memory — RSS cannot see device-local allocations
|
||||||
|
at all — and recommends restating the requirement as two figures.
|
||||||
|
|
||||||
|
**What is left is the frame-timing half, and it is the hard one.** NFR-P2, -P4, -P5, -P6, -P9, -P10,
|
||||||
|
-P11, -P12, -P13, -P14 and -P15 all need a probe inside a running Slint application, a GPU adapter,
|
||||||
|
or both. `dr-gpu/examples/frame_budget` is a real instrument for the GPU part and its results are
|
||||||
|
committed in [frame-budget.md](frame-budget.md) with the machine and profile named — but it is run
|
||||||
|
by hand, and the guard version in CI skips itself where there is no adapter, which is the normal
|
||||||
|
case on a runner. So the claim to take from this section is now narrower than it was, and still
|
||||||
|
true: **a scroll that dropped to 30 fps tomorrow would reach a user before it reached CI.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -320,12 +363,19 @@ fixed before spike S9", because S9 both validates R1 and calibrates what toleran
|
|||||||
The threshold was never fixed and S9 has not run, so R1 currently has no acceptance criterion at
|
The threshold was never fixed and S9 has not run, so R1 currently has no acceptance criterion at
|
||||||
all — there is nothing a test could assert.
|
all — there is nothing a test could assert.
|
||||||
|
|
||||||
Worse, the matrix reports R1 as *covered*. Both of its tags are string literals inside the
|
The matrix used to report R1 as *covered*, and what covered it was two string literals: fixtures
|
||||||
traceability tool's own unit tests (`tools/traceability/src/lib.rs`), which the tool scans along with
|
inside the traceability tool's own unit tests, which the tool scans along with everything else,
|
||||||
everything else, because a fixture demonstrating tag extraction is indistinguishable from a tag.
|
because a fixture demonstrating tag extraction was indistinguishable from a tag. The extractor now
|
||||||
NFR-OPS-1 is covered the same way, from a tag on `compute_coverage` — and no rotating, size-capped
|
asks where the tag sits — a tag is the first word of a comment, not a string appearing anywhere on a
|
||||||
on-disk log exists; logging goes to stderr and logcat. These are two of the cases
|
line — and R1 is untagged again, which is the honest reading while it has no acceptance criterion to
|
||||||
[CONTRIBUTING.md](../CONTRIBUTING.md) already warns about, now named.
|
tag anything against.
|
||||||
|
NFR-OPS-1 was covered by tags that were real rather than fixtures, which is the worse case of the
|
||||||
|
two: one on `compute_coverage` and one on the gesture extractor, both on the traceability tool. A
|
||||||
|
coverage calculation and a documentation generator are not diagnostics under any reading, and the
|
||||||
|
requirement asks for a rotating, size-capped on-disk log in the XDG state directory, credential
|
||||||
|
redaction, and a consented diagnostics bundle. None of that exists — logging goes to stderr and
|
||||||
|
logcat — so both tags have been removed and NFR-OPS-1 is untagged. It is the case
|
||||||
|
[CONTRIBUTING.md](../CONTRIBUTING.md) warns about in its own words: a tag proves a tag exists.
|
||||||
|
|
||||||
**R2 — Efficient display of huge RAW libraries.** Its acceptance criterion contains "*(figure
|
**R2 — Efficient display of huge RAW libraries.** Its acceptance criterion contains "*(figure
|
||||||
TBD)*" — the scroll velocity below which no cell may render as a placeholder — and asks for a stated
|
TBD)*" — the scroll velocity below which no cell may render as a placeholder — and asks for a stated
|
||||||
|
|||||||
+85
-8
@@ -55,7 +55,7 @@ These are the user's stated requirements, restated as testable criteria.
|
|||||||
| **R2** | Efficient display of huge RAW libraries | A 50,000-image catalog scrolls at 60fps sustained, with a stated prefetch margin and cache-hit rate sufficient that no cell renders as a placeholder at a scroll velocity of *(figure TBD)* rows/second. Catalog opens in under 2s. |
|
| **R2** | Efficient display of huge RAW libraries | A 50,000-image catalog scrolls at 60fps sustained, with a stated prefetch margin and cache-hit rate sufficient that no cell renders as a placeholder at a scroll velocity of *(figure TBD)* rows/second. Catalog opens in under 2s. |
|
||||||
| **R3** | Make a RAW beautiful at 8-bit output | Full non-destructive develop chain at high internal precision, with camera input profiles (FR-DEV-3e) and a colour-managed path to 8/16-bit export. |
|
| **R3** | Make a RAW beautiful at 8-bit output | Full non-destructive develop chain at high internal precision, with camera input profiles (FR-DEV-3e) and a colour-managed path to 8/16-bit export. |
|
||||||
| **R4** | HW acceleration and parallelism | All per-pixel work runs on GPU compute. CPU work (decode, I/O) is parallelised across cores. The UI executor never blocks on image work (NFR-ARCH-1). |
|
| **R4** | HW acceleration and parallelism | All per-pixel work runs on GPU compute. CPU work (decode, I/O) is parallelised across cores. The UI executor never blocks on image work (NFR-ARCH-1). |
|
||||||
| **R5** | Work on downscaled proxies for display | Display pipeline operates at viewport resolution, not source resolution. Only visible tiles are computed; panning recomputes only newly exposed tiles. |
|
| **R5** | Work on downscaled proxies for display | Display pipeline operates at viewport resolution, not source resolution. The tiling clauses this criterion used to carry have been struck — see below. |
|
||||||
| **R6** | Nextcloud integration | Browse, download, and upload images and edit metadata against a Nextcloud instance, offline-capable. |
|
| **R6** | Nextcloud integration | Browse, download, and upload images and edit metadata against a Nextcloud instance, offline-capable. |
|
||||||
|
|
||||||
**On R1's tolerance.** An earlier draft required output to be *bit-identical* across platforms.
|
**On R1's tolerance.** An earlier draft required output to be *bit-identical* across platforms.
|
||||||
@@ -72,6 +72,31 @@ Where genuine bit-identity is required — cache keys, edit-graph hashing (§5.2
|
|||||||
applies to *integer* operations on CPU-side state, which are deterministic, never to GPU float
|
applies to *integer* operations on CPU-side state, which are deterministic, never to GPU float
|
||||||
results.
|
results.
|
||||||
|
|
||||||
|
**On R5's tiling.** An earlier draft added two clauses to R5's criterion: *"only visible tiles are
|
||||||
|
computed; panning recomputes only newly exposed tiles"*. They have been struck, and the reason is
|
||||||
|
the same one FR-DSP-2 was rewritten for rather than implemented:
|
||||||
|
[frame-budget.md](frame-budget.md) measured it.
|
||||||
|
|
||||||
|
R5's actual demand is met and tested. The display pipeline works at viewport resolution:
|
||||||
|
`Framing::view` shrinks the sampled region while the render target keeps its size, so zooming raises
|
||||||
|
the resolution the pipeline works at rather than magnifying pixels already drawn, and
|
||||||
|
`core/dr-gpu/tests/zoom_resolution.rs` establishes it as a pixel equality rather than an impression
|
||||||
|
of sharpness.
|
||||||
|
|
||||||
|
Tiling is a different claim, and it was written in as though it were the mechanism by which the
|
||||||
|
first one is achieved. It is not. Recomputing the *entire* 4K viewport costs 4.5 ms of a 16 ms
|
||||||
|
budget, so a perfect tile cache saves at most that, in exchange for a cache keyed by
|
||||||
|
`(VersionId, tile, zoom, graph_hash_prefix)` that has to stay correct across every parameter change
|
||||||
|
in the graph — a large correctness surface bought with a small number. And for the one stage that
|
||||||
|
does miss the budget, tiling makes it worse: that stage is a convolution, and a tiled convolution
|
||||||
|
reads a halo per tile, so at the 52 px radius measured at 4K a 256 px tile would read (256+104)²
|
||||||
|
taps instead of 256², very nearly twice the work.
|
||||||
|
|
||||||
|
The intent behind the struck clauses — that the display path must not do work proportional to the
|
||||||
|
source image — survives in the clause that remains, which is the honest statement of it. Tiling
|
||||||
|
stays where FR-DSP-2 puts it: a scheduling concern for export and thumbnailing, both of which
|
||||||
|
already run off the frame path.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Functional requirements
|
## 3. Functional requirements
|
||||||
@@ -181,10 +206,34 @@ destructive action, since that figure is what tells the user whether they meant
|
|||||||
CR3), Nikon (NEF), Sony (ARW), Fujifilm (RAF, including X-Trans), Panasonic (RW2), Olympus (ORF),
|
CR3), Nikon (NEF), Sony (ARW), Fujifilm (RAF, including X-Trans), Panasonic (RW2), Olympus (ORF),
|
||||||
Adobe DNG. Additional formats are a coverage goal, not a launch blocker.
|
Adobe DNG. Additional formats are a coverage goal, not a launch blocker.
|
||||||
|
|
||||||
**FR-RAW-2 — Decoder abstraction.** RAW decoding sits behind a trait taking a `SourceRef`
|
**FR-RAW-2 — Decoder abstraction.** RAW decoding takes **bytes**, never a filesystem path and never
|
||||||
(FR-CAT-1a), not a filesystem path, so the same decoder works over a local file, an Android SAF
|
a reference it would have to resolve. Resolving a `SourceRef` (FR-CAT-1a) to bytes is
|
||||||
document, or a byte range fetched from Nextcloud. A second implementation may be added for broader
|
`Storage::open`'s job and happens at the caller, so the same decoder works over a local file, an
|
||||||
camera coverage without changing callers (D2).
|
Android SAF document, or a byte range fetched from Nextcloud. A second implementation may be added
|
||||||
|
for broader camera coverage without changing callers (D2).
|
||||||
|
|
||||||
|
*On the change of mechanism.* This clause used to require "a trait taking a `SourceRef`". The
|
||||||
|
purpose — that no decoder API takes a path, so nothing in the decode path assumes a filesystem —
|
||||||
|
is met and is not in question: `dr_decode::decode` takes `&[u8]`, and there is no path-based entry
|
||||||
|
point in the crate. The mechanism was wrong, and stating it that way would have made the design
|
||||||
|
worse.
|
||||||
|
|
||||||
|
A `SourceRef` is opaque by construction; the only thing that turns one into readable bytes is
|
||||||
|
`Storage`, in `platform/dr-plat`. A decoder taking a `SourceRef` would therefore have to take a
|
||||||
|
`Storage` alongside it, which moves retry, permission loss and remote fetching inside the decoder
|
||||||
|
and leaves it constructible only where a `Storage` exists. Bytes in, image out, is both narrower and
|
||||||
|
more portable: the decoder has no idea where its input came from, which is the property this
|
||||||
|
requirement is actually asking for.
|
||||||
|
|
||||||
|
It also serves the Nextcloud case better rather than worse, which is the one a byte-oriented API
|
||||||
|
looks like it would lose. `dr_decode::HEADER_BYTES` declares how much of a file the decoder needs to
|
||||||
|
read metadata, and `import.rs` fetches exactly that range through `Storage::read_range` before
|
||||||
|
calling `dr_decode::metadata`. The decoder states its requirement and the storage layer satisfies
|
||||||
|
it; a decoder holding its own `SourceRef` would have had to implement the range policy itself.
|
||||||
|
|
||||||
|
What is genuinely not built is the trait. There is one decoder, reached through free functions, so
|
||||||
|
"without changing callers" is a claim nothing yet tests. The clause stands as written and is
|
||||||
|
outstanding work, not a satisfied one.
|
||||||
|
|
||||||
**FR-RAW-3 — Sensor data handling.** Correctly apply per-camera black/white levels, CFA pattern
|
**FR-RAW-3 — Sensor data handling.** Correctly apply per-camera black/white levels, CFA pattern
|
||||||
identification, and camera-native colour matrices. Demosaic quality shall be selectable, with at
|
identification, and camera-native colour matrices. Demosaic quality shall be selectable, with at
|
||||||
@@ -493,8 +542,24 @@ region.
|
|||||||
|
|
||||||
### 3.6 Export
|
### 3.6 Export
|
||||||
|
|
||||||
**FR-EXP-1 — Formats.** Export to JPEG, PNG, TIFF (8 and 16-bit), and AVIF or JPEG XL. Quality,
|
**FR-EXP-1 — Formats.** Export to JPEG, PNG, and TIFF (8 and 16-bit). Quality, chroma subsampling,
|
||||||
chroma subsampling, and bit depth are configurable.
|
and bit depth are configurable.
|
||||||
|
|
||||||
|
**AVIF and JPEG XL are post-v1** and are not part of this requirement's acceptance. Either may still
|
||||||
|
be listed in the settings page before its encoder exists, on one condition: choosing it shall fail
|
||||||
|
with a typed error naming the format, never with a file. The test
|
||||||
|
`every_offered_format_either_encodes_or_explains_itself` walks every format the page offers and
|
||||||
|
enforces exactly that, so a format cannot be added to the picker and quietly reach an encoder that
|
||||||
|
does not handle it.
|
||||||
|
|
||||||
|
*On splitting this requirement.* It read as one undifferentiated list — "JPEG, PNG, TIFF (8 and
|
||||||
|
16-bit), and AVIF or JPEG XL" — which left it neither met nor unmet. Three formats, both TIFF
|
||||||
|
depths, and the configurability clause are built, encode, embed their profile, and are tested; the
|
||||||
|
fourth item is a deliberate deferral, and the encoders for it are the two with the least settled
|
||||||
|
library support. Fused into one sentence, the only choices were a tag asserting something untrue or
|
||||||
|
no tag at all, and the second is the worse of the two: it would have removed the register's record
|
||||||
|
of four-fifths of a requirement that is finished. The deferral is now stated where it can be read as
|
||||||
|
a decision rather than inferred from an error variant.
|
||||||
|
|
||||||
**FR-EXP-2 — Colour space.** Export in a selectable output colour space (sRGB, Display P3,
|
**FR-EXP-2 — Colour space.** Export in a selectable output colour space (sRGB, Display P3,
|
||||||
Adobe RGB, ProPhoto), with the correct ICC profile embedded.
|
Adobe RGB, ProPhoto), with the correct ICC profile embedded.
|
||||||
@@ -1305,13 +1370,25 @@ These are targets to design against and measure, on the reference desktop
|
|||||||
| **NFR-P14** | Focus peaking overlay ready | < 100 ms after preview | < 150 ms |
|
| **NFR-P14** | Focus peaking overlay ready | < 100 ms after preview | < 150 ms |
|
||||||
| **NFR-P15** | Drawn mask stroke → visible (ARCH §6.11) | < 16 ms, no cursor lag | < 16 ms |
|
| **NFR-P15** | Drawn mask stroke → visible (ARCH §6.11) | < 16 ms, no cursor lag | < 16 ms |
|
||||||
| **NFR-P10** | Touch gesture → visual response | < 16 ms | < 16 ms |
|
| **NFR-P10** | Touch gesture → visual response | < 16 ms | < 16 ms |
|
||||||
| **NFR-P11** | Layout class transition (window resize) | No dropped frames, no state loss | n/a |
|
| **NFR-P11** | Layout class transition (window resize) | No dropped frames. No loss of *photographic* state: the open image and version, the selection, scroll position, the in-progress edit and its undo history, and the current mode. Panel disclosure is explicitly exempt — see below | n/a |
|
||||||
| **NFR-P12** | Warm-start shader pipeline setup (cached) | < 100 ms | < 100 ms |
|
| **NFR-P12** | Warm-start shader pipeline setup (cached) | < 100 ms | < 100 ms |
|
||||||
|
|
||||||
Every target above requires a stated measurement method, workload, and pass threshold before it is
|
Every target above requires a stated measurement method, workload, and pass threshold before it is
|
||||||
testable. NFR-P8 in particular must state whether it measures RSS inclusive or exclusive of GPU
|
testable. NFR-P8 in particular must state whether it measures RSS inclusive or exclusive of GPU
|
||||||
allocations, and whether it holds after SQLite's page cache warms on a 50k catalog.
|
allocations, and whether it holds after SQLite's page cache warms on a 50k catalog.
|
||||||
|
|
||||||
|
**On what NFR-P11 means by state.** It said "no state loss", which the implementation contradicts on
|
||||||
|
purpose, so the requirement has been made specific rather than left to be read as forbidding
|
||||||
|
something it should not. `apply_layout_class` discards the user's panel open/closed choices when the
|
||||||
|
class changes, and the argument for that is sound: a choice made in landscape answers a different
|
||||||
|
question from the one portrait asks, and carrying it across is how a photographer ends up with a
|
||||||
|
232 px sidebar on a screen with no room for it and no memory of having asked for it. Panel
|
||||||
|
disclosure is a *default*, re-derived per class, with the user's disagreement remembered only within
|
||||||
|
the class where it was expressed.
|
||||||
|
|
||||||
|
Everything the photographer produced or navigated to is a different matter, and none of it may be
|
||||||
|
touched by a resize. That is the list in the criterion, and it is the testable half.
|
||||||
|
|
||||||
**Performance regressions fail the build.** §9's benchmark suite runs per-commit; a regression
|
**Performance regressions fail the build.** §9's benchmark suite runs per-commit; a regression
|
||||||
beyond a stated tolerance is a build failure, not a notification. Performance work rots otherwise.
|
beyond a stated tolerance is a build failure, not a notification. Performance work rots otherwise.
|
||||||
|
|
||||||
|
|||||||
@@ -297,6 +297,152 @@ millisecond for the `point` and `all` chains, and the codegen tests still pass b
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## TD-6 — The quietest ink does not reach WCAG AA, and the rule does not reach 3:1
|
||||||
|
|
||||||
|
**Breaks:** [requirements.md](requirements.md) NFR-A11Y-2 — "non-canvas UI meets WCAG AA contrast".
|
||||||
|
|
||||||
|
### What it does
|
||||||
|
|
||||||
|
`style.yaml` sets three inks and four surfaces. Measured as WCAG 2 contrast ratios (sRGB relative
|
||||||
|
luminance, the standard formula), against the surfaces each ink is actually drawn on:
|
||||||
|
|
||||||
|
| ink | on `ground` | on `surface` | on `surface-raised` | on `hover` | on `selected` |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `ink` #EDEEF0 | 16.02 | 14.69 | 13.03 | 11.39 | 9.68 |
|
||||||
|
| `ink-dim` #9EA1A6 | 7.18 | 6.58 | 5.84 | 5.10 | **4.34** |
|
||||||
|
| `ink-faint` #71747A | **3.97** | **3.64** | **3.23** | **2.82** | **2.40** |
|
||||||
|
| `warn-ink` #C9A05A | 7.67 | 7.03 | 6.24 | 5.45 | 4.64 |
|
||||||
|
| `rule` #323438 | **1.49** | **1.37** | **1.21** | **1.06** | **1.11** |
|
||||||
|
|
||||||
|
Every text size in the application is 11px, 13px, 17px or 24px, and WCAG's "large text" relief
|
||||||
|
begins at 18.66px bold or 24px regular — so all four of those thresholds are the normal-text one,
|
||||||
|
**4.5:1**, except the masthead. Bold entries fail it.
|
||||||
|
|
||||||
|
The inverted cases pass and are worth stating so nobody re-measures them: `ground` on `active`
|
||||||
|
(#FFFFFF) is 18.60, on `active-dim` 11.20, on `active-pressed` 5.95, on `selected-ring` 13.02. The
|
||||||
|
near-white fills that `Button.primary`, `FilterChip.active` and the held tool-rail entry use are
|
||||||
|
the *best*-contrasting text in the interface, not the worst.
|
||||||
|
|
||||||
|
So the failures are exactly two, and neither is where one would guess:
|
||||||
|
|
||||||
|
- **`ink-faint` reaches 4.5:1 nowhere at all.** It is the ink for `Caption`, `PanelHeading`,
|
||||||
|
`Disclosure`, `Value`'s placeholder state and `FilterChip`'s count — every hint, every section
|
||||||
|
name, every "3 photographs" under a title.
|
||||||
|
- **`rule` reaches 3:1 nowhere.** WCAG 1.4.11 asks 3:1 of the boundary of a control the user must
|
||||||
|
perceive, and `rule` is the border of every `Button`, `Field`, `Panel`, `ChoiceChip` and
|
||||||
|
`IconButton`. An unfilled secondary button is a 1.4:1 outline on a 1.2:1 background.
|
||||||
|
|
||||||
|
`ink-dim` on `selected` at 4.34 is a third case, marginal enough that a two-point lift fixes it.
|
||||||
|
|
||||||
|
### Why
|
||||||
|
|
||||||
|
Not an oversight — the direct consequence of the palette's own argument, which `style.yaml`'s
|
||||||
|
preamble makes at length and correctly. The chrome is deliberately quiet because a bright surround
|
||||||
|
biases how a photograph is judged, and hue is banned outright because an accent beside the image
|
||||||
|
shifts the perception of nearby colours. What is left to signal with is luminance, and the palette
|
||||||
|
spends its luminance range on the *photograph*, keeping the chrome inside a narrow band above the
|
||||||
|
ground.
|
||||||
|
|
||||||
|
A narrow band is precisely what a contrast ratio measures. `ink-faint` exists to be skipped by the
|
||||||
|
reader who did not stop to look; that is a real design intent, and "text you are meant to skip"
|
||||||
|
and "text everyone can read" are in genuine tension rather than one being a mistake.
|
||||||
|
|
||||||
|
### What it costs
|
||||||
|
|
||||||
|
The photographer who cannot read a caption cannot read *any* caption, on any screen — this is one
|
||||||
|
token, so it fails everywhere at once. The hints under the settings switches say what a setting
|
||||||
|
costs, the section names say what a panel is, and the counts say how big a filter is. None of it is
|
||||||
|
decorative.
|
||||||
|
|
||||||
|
### Paying it off
|
||||||
|
|
||||||
|
Two token changes, and the second is the awkward one.
|
||||||
|
|
||||||
|
`ink-faint` needs roughly #8A8D93 to clear 4.5:1 against `surface-raised`, the darkest surface it
|
||||||
|
is drawn on that matters — which puts it about where `ink-dim` sits today and collapses the
|
||||||
|
three-ink scale to two. So the real fix is to re-derive all three inks against the surfaces rather
|
||||||
|
than to nudge one: the scale wants to start higher and keep its steps, not compress.
|
||||||
|
|
||||||
|
`rule` needs about #4A4D52 for 3:1 against `surface`. That is a visibly stronger line, and the
|
||||||
|
preamble's "instrument rather than absence" reasoning applies to it as much as to the greys — this
|
||||||
|
is a look change, not a number change, and it should be looked at rather than computed.
|
||||||
|
|
||||||
|
Both are decisions about how the application appears next to a photograph, which is the one thing
|
||||||
|
this palette was designed around. They want a screenshot and an opinion, not a patch.
|
||||||
|
|
||||||
|
**Done when:** every `Theme` ink reaches 4.5:1 against every surface it is drawn on, `rule` reaches
|
||||||
|
3:1 against `surface` and `surface-raised`, and a test recomputes those ratios from `style.yaml` so
|
||||||
|
the next palette edit cannot quietly undo it. The table above is the baseline to compare against.
|
||||||
|
|
||||||
|
### Not in scope
|
||||||
|
|
||||||
|
The histogram's `plot-*` inks (2.36 for `plot-luma` on `ground`) are drawn *on* the canvas, and
|
||||||
|
NFR-A11Y-2 scopes contrast to non-canvas UI. NFR-A11Y-3 covers what those need instead, and is
|
||||||
|
already met — the readouts name the channel in words.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TD-7 — Platform font scaling is not honoured
|
||||||
|
|
||||||
|
**Breaks:** [requirements.md](requirements.md) NFR-A11Y-2 — "platform font scaling is honoured
|
||||||
|
without clipping".
|
||||||
|
|
||||||
|
### What it does
|
||||||
|
|
||||||
|
Nothing at all, which is the entry. Every type size is a constant in `style.yaml` — 11, 13, 17, 24
|
||||||
|
— read as `Theme.text-sm` and friends at 65 call sites, and there is no multiplier anywhere between
|
||||||
|
the platform's font-size preference and those numbers. `scale_factor()` is read in `display_ui.rs`
|
||||||
|
and in `lib.rs`, but only to size the canvas in physical pixels for the render; it is display DPI,
|
||||||
|
which Slint already applies to logical lengths, and it is not the user's text-size setting. A
|
||||||
|
photographer who sets 130% text on GNOME or Android gets an application that ignores it.
|
||||||
|
|
||||||
|
### Why
|
||||||
|
|
||||||
|
Because honouring it is not a multiplier, and pretending it is would be worse than not doing it.
|
||||||
|
|
||||||
|
The layout is built on constants that are not derived from the type size: `control-height` 28,
|
||||||
|
`touch-target` 44, `row-height` 26, `rail-entry-height` 54, `panel-width` 360, and a dozen fixed
|
||||||
|
heights written at their call sites — `ParamSlider`'s 46px, the folder picker's 220px box, the
|
||||||
|
readout column's 30px. Scaling the type alone clips against every one of them, silently, because
|
||||||
|
Slint elides rather than errors. `SwatchSlider` is the sharpest case: 12 hue bands × 3 channels in
|
||||||
|
a 360px column, sized so that a track and a swatch and a three-character readout fit on one line.
|
||||||
|
|
||||||
|
And the failure is invisible to the person shipping it. The requirement's own phrase is "without
|
||||||
|
clipping", and clipping is exactly what a screenshot at 100% cannot show — the memory note on
|
||||||
|
verifying Slint changes exists because these files have a history of compiling, rendering and being
|
||||||
|
wrong.
|
||||||
|
|
||||||
|
### What it costs
|
||||||
|
|
||||||
|
The user for whom this matters most is not the screen-reader user the rest of this branch serves —
|
||||||
|
it is the one with usable but poor sight, who reads the interface and needs it larger. The
|
||||||
|
application is unusable to them at any setting, and there is no partial credit: text scaling is a
|
||||||
|
system-wide preference, so an app that ignores it is the one thing on the desktop that did.
|
||||||
|
|
||||||
|
### Paying it off
|
||||||
|
|
||||||
|
In the order the pieces depend on each other:
|
||||||
|
|
||||||
|
1. **A scale token.** `Theme.text-sm` and the rest become `base × Theme.type-scale`, with the
|
||||||
|
scale an `in-out` property Rust writes at startup from the platform. `build.rs` already emits
|
||||||
|
`in-out` tokens under `live-style`, so the codegen half of this exists and is proven — that
|
||||||
|
feature is the mechanism, one line from being general.
|
||||||
|
2. **A source for the number.** GNOME publishes `text-scaling-factor` over the settings portal;
|
||||||
|
Android has `Configuration.fontScale` through JNI, beside the calls `lib.rs` already makes for
|
||||||
|
`ACTION_VIEW`. Both want a default of 1.0 and a sane clamp — 0.8 to 2.0 — because a user who has
|
||||||
|
set 300% for a phone launcher has not asked for a 72px slider readout.
|
||||||
|
3. **The constants that are not type.** Every fixed height a *label* sits inside has to follow the
|
||||||
|
scale; every touch target must not shrink and need not grow. That is the work, and it is where
|
||||||
|
the 46px and 220px literals get read one at a time.
|
||||||
|
4. **Evidence.** Screenshots at 1.0, 1.3 and 2.0 of the develop column, the settings page and the
|
||||||
|
colour mixer — the three densest layouts — because "without clipping" is a claim about the
|
||||||
|
worst case and nothing else will show it.
|
||||||
|
|
||||||
|
**Done when:** the develop column, the settings page and the colour mixer render at a 2.0 scale
|
||||||
|
with no elided label and no touch target under 44 logical pixels.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Related, and deliberately not here
|
## Related, and deliberately not here
|
||||||
|
|
||||||
The window-move rule, the grid's ordering index and the whole-library readout cache were *fixed*
|
The window-move rule, the grid's ordering index and the whole-library readout cache were *fixed*
|
||||||
|
|||||||
+74
-74
File diff suppressed because one or more lines are too long
@@ -8,7 +8,12 @@ license.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
dr-types.workspace = true
|
dr-types.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
log.workspace = true
|
# `std` is not one of `log`'s default features, and `diagnostics::install`
|
||||||
|
# needs `set_boxed_logger`, which is gated behind it. Stated here rather than
|
||||||
|
# left to feature unification with whichever dependant happens to pull
|
||||||
|
# `env_logger` in — that unification holds for `cargo build --workspace` and
|
||||||
|
# not for `cargo build -p dr-plat`.
|
||||||
|
log = { workspace = true, features = ["std"] }
|
||||||
|
|
||||||
[target.'cfg(all(unix, not(target_os = "android")))'.dependencies]
|
[target.'cfg(all(unix, not(target_os = "android")))'.dependencies]
|
||||||
keyring.workspace = true
|
keyring.workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,622 @@
|
|||||||
|
//! TRACES: NFR-OPS-2 | NFR-SEC-2
|
||||||
|
//! Local crash capture.
|
||||||
|
//!
|
||||||
|
//! # What this is, and the half it deliberately is not
|
||||||
|
//!
|
||||||
|
//! NFR-OPS-2 is two sentences: *local crash capture always; upload only on
|
||||||
|
//! explicit opt-in.* Only the first is built here, and the second is not
|
||||||
|
//! half-built either — there is no upload path, no endpoint, no queue and no
|
||||||
|
//! "send this later" flag. Opt-in upload needs a server to receive it and a
|
||||||
|
//! consent flow that states what leaves the device (NFR-SEC-4, and the same
|
||||||
|
//! preview-and-consent step NFR-OPS-1 requires of the diagnostics bundle);
|
||||||
|
//! neither exists, and a transport built ahead of the consent is exactly the
|
||||||
|
//! shape of thing that later gets switched on by default.
|
||||||
|
//!
|
||||||
|
//! So a crash record is a file on the user's own disk. Nothing reads it but a
|
||||||
|
//! person.
|
||||||
|
//!
|
||||||
|
//! # Why a panic is worth writing down at all
|
||||||
|
//!
|
||||||
|
//! NFR-ARCH-4 says no worker error may panic the process, and the application
|
||||||
|
//! is built that way — errors are typed and attached to the image or job they
|
||||||
|
//! belong to. A panic is therefore, by construction, a *bug*: an invariant
|
||||||
|
//! this codebase believed and got wrong. Before this, one of those was
|
||||||
|
//! invisible on desktop (stderr, discarded with the terminal) and one line of
|
||||||
|
//! `log::error!` on Android. What the user saw was a job that stopped, or a
|
||||||
|
//! channel that closed and a control that went dead, with nothing to report.
|
||||||
|
//!
|
||||||
|
//! # What goes in a record, and what may never
|
||||||
|
//!
|
||||||
|
//! The rule the content is chosen under is NFR-SEC-2 — credentials never reach
|
||||||
|
//! logs or plain files — extended to the thing this application is actually
|
||||||
|
//! about: **a user's library is private, and its shape is private too.** A
|
||||||
|
//! path is not a neutral technical detail here. `/home/anna/Photos/2019
|
||||||
|
//! Divorce/` names something about a person, and a crash record is a file that
|
||||||
|
//! gets attached to a bug report by someone trying to be helpful.
|
||||||
|
//!
|
||||||
|
//! Hence [`redact`], which is applied to the panic message *and* to the
|
||||||
|
//! backtrace before either is written, and which is deliberately blunt: it
|
||||||
|
//! removes anything that looks like a path or a URL, keeping only the basename
|
||||||
|
//! of `.rs` files so a backtrace is still readable. Over-redaction costs
|
||||||
|
//! legibility; under-redaction costs a user something they cannot take back.
|
||||||
|
//!
|
||||||
|
//! NFR-SEC-5 — face data never enters a diagnostics bundle or crash report
|
||||||
|
//! "under any configuration" — is met structurally rather than by filtering:
|
||||||
|
//! this module reads no catalog, opens no image, and touches no account. A
|
||||||
|
//! record is assembled from the panic hook's own arguments and from
|
||||||
|
//! [`std::env::consts`], and there is no code path from here to an embedding,
|
||||||
|
//! a crop, or a cluster. The message length cap is the backstop for the
|
||||||
|
//! remaining case — a panic payload that some *other* module formatted a large
|
||||||
|
//! value into.
|
||||||
|
//!
|
||||||
|
//! # What this leaves cheaper for NFR-OPS-1
|
||||||
|
//!
|
||||||
|
//! The rotating on-disk log is unbuilt, and it wants three things that are
|
||||||
|
//! here: [`state_dir`] (the XDG state directory, resolved once, overridable
|
||||||
|
//! for Android where XDG does not exist), [`redact`] (NFR-OPS-1's "automatic
|
||||||
|
//! redaction of credentials and tokens" is the same function), and `prune`
|
||||||
|
//! (size-capped rotation is this counting files instead of bytes). A log
|
||||||
|
//! belongs beside `crash/` on desktop, and the diagnostics
|
||||||
|
//! bundle then has one directory to collect.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
/// How many crash records are kept, newest first.
|
||||||
|
///
|
||||||
|
/// Ten because the useful pattern in a crash record is usually a *repeat* —
|
||||||
|
/// the same panic three launches running is a far stronger report than one —
|
||||||
|
/// and because these are a few kilobytes each, so the cap exists to stop a
|
||||||
|
/// crash loop filling a disk rather than to save space.
|
||||||
|
pub const KEEP_RECORDS: usize = 10;
|
||||||
|
|
||||||
|
/// The longest panic message written to a record.
|
||||||
|
///
|
||||||
|
/// A backstop, not a redaction: [`redact`] handles what must not be written at
|
||||||
|
/// all. This bounds what a panic that formatted something enormous into its
|
||||||
|
/// message — a decoded buffer, a `Vec` of embeddings — can put on disk.
|
||||||
|
const MAX_MESSAGE: usize = 2000;
|
||||||
|
|
||||||
|
/// Android's per-app directory, once the entry point has said what it is.
|
||||||
|
static STATE_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Declare the directory this application may keep state in.
|
||||||
|
///
|
||||||
|
/// Only Android needs to call this, and it must call it before a crash rather
|
||||||
|
/// than before the hook is installed — [`install`] resolves the directory at
|
||||||
|
/// crash time precisely so that the hook can go in first, covering the startup
|
||||||
|
/// it would otherwise miss. Neither `XDG_STATE_HOME` nor `HOME` is set there,
|
||||||
|
/// and the fallback would resolve to a path the app cannot write.
|
||||||
|
///
|
||||||
|
/// Later calls are ignored rather than racing, matching
|
||||||
|
/// `dr_sync::account::set_data_dir`, which the same entry point calls for the
|
||||||
|
/// same reason.
|
||||||
|
pub fn set_state_dir(dir: PathBuf) {
|
||||||
|
let _ = STATE_DIR.set(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where this application keeps state that is neither configuration nor cache.
|
||||||
|
///
|
||||||
|
/// `$XDG_STATE_HOME/darkroom`, falling back to `~/.local/state/darkroom`.
|
||||||
|
/// State rather than cache because a crash record must survive the sweep that
|
||||||
|
/// a cache directory exists to permit, and rather than config because it is
|
||||||
|
/// not something the user edits.
|
||||||
|
pub fn state_dir() -> PathBuf {
|
||||||
|
if let Some(d) = STATE_DIR.get() {
|
||||||
|
return d.clone();
|
||||||
|
}
|
||||||
|
std::env::var_os("XDG_STATE_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".local/state")
|
||||||
|
})
|
||||||
|
.join("darkroom")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where crash records are written.
|
||||||
|
pub fn crash_dir() -> PathBuf {
|
||||||
|
state_dir().join("crash")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the panic hook.
|
||||||
|
///
|
||||||
|
/// Call once, as early as the entry point can — before the window, before any
|
||||||
|
/// store, before anything that could itself panic. The directory is resolved
|
||||||
|
/// lazily inside the hook, so installing this before
|
||||||
|
/// [`set_state_dir`] is correct rather than merely tolerated.
|
||||||
|
///
|
||||||
|
/// The previously installed hook still runs afterwards. On desktop that is the
|
||||||
|
/// standard library's, which prints the panic to stderr, and a developer
|
||||||
|
/// watching a terminal should not lose that because the application started
|
||||||
|
/// writing files. stderr is the one surface that sees the message unredacted,
|
||||||
|
/// which is a considered exception: it is ephemeral, local, and never attached
|
||||||
|
/// to a bug report.
|
||||||
|
pub fn install(app_version: &str) {
|
||||||
|
let version = app_version.to_string();
|
||||||
|
let previous = std::panic::take_hook();
|
||||||
|
|
||||||
|
std::panic::set_hook(Box::new(move |info| {
|
||||||
|
// A panic inside a panic hook aborts the process, which would replace
|
||||||
|
// a diagnosable crash with an undiagnosable one. Everything below is
|
||||||
|
// written to be infallible, and this is the admission that "written to
|
||||||
|
// be" is not the same as "is".
|
||||||
|
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
let record = compose(&version, info);
|
||||||
|
// Redacted, because this is a log file and NFR-SEC-2 governs it.
|
||||||
|
// The unredacted form goes to stderr below, through the hook this
|
||||||
|
// one chained onto.
|
||||||
|
log::error!("panic: {}", record.summary);
|
||||||
|
match write_record(&crash_dir(), &record) {
|
||||||
|
// The name, not the path: the log this line lands in is a
|
||||||
|
// sibling of the record, and printing the directory would put
|
||||||
|
// the user's home in a file they may hand to someone.
|
||||||
|
Ok(path) => log::error!(
|
||||||
|
"crash record written: {}",
|
||||||
|
path.file_name().unwrap_or_default().to_string_lossy()
|
||||||
|
),
|
||||||
|
Err(e) => log::error!("could not write a crash record: {e}"),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
previous(info);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The crash records on disk, newest first.
|
||||||
|
///
|
||||||
|
/// For a future diagnostics bundle, and for a person looking for the file to
|
||||||
|
/// attach to a report.
|
||||||
|
pub fn records() -> Vec<PathBuf> {
|
||||||
|
let Ok(entries) = std::fs::read_dir(crash_dir()) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut out: Vec<(i64, PathBuf)> = entries
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|e| {
|
||||||
|
let path = e.path();
|
||||||
|
Some((timestamp_of(&path)?, path))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
out.sort_by_key(|(when, _)| std::cmp::Reverse(*when));
|
||||||
|
out.into_iter().map(|(_, p)| p).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One crash, formatted.
|
||||||
|
struct Record {
|
||||||
|
/// The whole file.
|
||||||
|
body: String,
|
||||||
|
/// One redacted line, for the log.
|
||||||
|
summary: String,
|
||||||
|
when: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a record from what the panic hook was handed.
|
||||||
|
///
|
||||||
|
/// Split from the writing so the *content* rules — what is included, what is
|
||||||
|
/// redacted, what is capped — are testable without a filesystem, and so a
|
||||||
|
/// future diagnostics bundle can reuse the same composition.
|
||||||
|
fn compose(version: &str, info: &std::panic::PanicHookInfo<'_>) -> Record {
|
||||||
|
let payload = info.payload();
|
||||||
|
let message = payload
|
||||||
|
.downcast_ref::<&str>()
|
||||||
|
.copied()
|
||||||
|
.or_else(|| payload.downcast_ref::<String>().map(|s| s.as_str()))
|
||||||
|
// A panic can carry any `Any`, and `panic_any` is used by some
|
||||||
|
// libraries. There is nothing to print, and saying so is better than
|
||||||
|
// an empty field that reads like a bug in this code.
|
||||||
|
.unwrap_or("(panic payload was not a string)");
|
||||||
|
|
||||||
|
let message = redact(&truncate(message, MAX_MESSAGE));
|
||||||
|
let at = info
|
||||||
|
.location()
|
||||||
|
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
|
||||||
|
// `location()` is a compile-time source path, not one of the user's,
|
||||||
|
// but it goes through the same redaction: a dependency built from a
|
||||||
|
// registry checkout carries the *builder's* home directory in it.
|
||||||
|
.map(|s| redact(&s))
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let thread = std::thread::current()
|
||||||
|
.name()
|
||||||
|
.unwrap_or("unnamed")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let when = now();
|
||||||
|
let summary = format!("{message} (at {at}, thread {thread})");
|
||||||
|
|
||||||
|
let body = format!(
|
||||||
|
"darkroom-crash 1\n\
|
||||||
|
version: {version}\n\
|
||||||
|
when: {when}\n\
|
||||||
|
os: {}\n\
|
||||||
|
arch: {}\n\
|
||||||
|
thread: {thread}\n\
|
||||||
|
at: {at}\n\
|
||||||
|
message: {message}\n\
|
||||||
|
backtrace:\n{}\n",
|
||||||
|
std::env::consts::OS,
|
||||||
|
std::env::consts::ARCH,
|
||||||
|
redact(&std::backtrace::Backtrace::force_capture().to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
Record {
|
||||||
|
body,
|
||||||
|
summary,
|
||||||
|
when,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write one record into `dir`, then prune.
|
||||||
|
///
|
||||||
|
/// Takes the directory rather than calling [`crash_dir`] so a test can drive
|
||||||
|
/// the real writing path without an environment variable and without a
|
||||||
|
/// `OnceLock` it cannot reset.
|
||||||
|
fn write_record(dir: &Path, record: &Record) -> std::io::Result<PathBuf> {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
// The pid distinguishes two processes crashing in the same second, which
|
||||||
|
// is not hypothetical: a background export and the app can be separate
|
||||||
|
// processes on desktop, and a crash loop retries fast.
|
||||||
|
let path = dir.join(format!("crash-{}-{}.txt", record.when, std::process::id()));
|
||||||
|
std::fs::write(&path, &record.body)?;
|
||||||
|
prune(dir, KEEP_RECORDS);
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete all but the `keep` newest records in `dir`.
|
||||||
|
///
|
||||||
|
/// Best-effort: failing to delete an old record is no reason to lose the new
|
||||||
|
/// one, which is already on disk.
|
||||||
|
fn prune(dir: &Path, keep: usize) {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut found: Vec<(i64, PathBuf)> = entries
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|e| {
|
||||||
|
let path = e.path();
|
||||||
|
Some((timestamp_of(&path)?, path))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
found.sort_by_key(|(when, _)| std::cmp::Reverse(*when));
|
||||||
|
for (_, path) in found.into_iter().skip(keep) {
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the timestamp back out of a record's filename.
|
||||||
|
///
|
||||||
|
/// Doubles as the filter that keeps anything else in the directory — an
|
||||||
|
/// editor's backup file, a log — from being counted as a crash record or
|
||||||
|
/// pruned as one.
|
||||||
|
fn timestamp_of(path: &Path) -> Option<i64> {
|
||||||
|
let name = path.file_name()?.to_str()?;
|
||||||
|
let rest = name.strip_prefix("crash-")?.strip_suffix(".txt")?;
|
||||||
|
rest.split('-').next()?.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove from `text` everything that would say something about the user.
|
||||||
|
///
|
||||||
|
/// # The rule, and why it is this blunt one
|
||||||
|
///
|
||||||
|
/// A token containing `/` is treated as a path or a URL and removed. Rust
|
||||||
|
/// source files are the exception and keep their basename, because a backtrace
|
||||||
|
/// with no filenames is close to useless and `library.rs:1270` says nothing
|
||||||
|
/// about anybody. A token that reads like a secret — a long opaque run, or the
|
||||||
|
/// value beside a word like `password` — is removed regardless of shape.
|
||||||
|
///
|
||||||
|
/// Blunter than a list of known-sensitive shapes on purpose. The cost of
|
||||||
|
/// over-redaction is a diagnostic that is harder to read; the cost of
|
||||||
|
/// under-redaction is a directory listing of somebody's photographs in a file
|
||||||
|
/// they may attach to a public bug report. Those are not comparable, and this
|
||||||
|
/// is not the place to be clever about the boundary.
|
||||||
|
///
|
||||||
|
/// Known limits, stated rather than hidden: a path with no `/` in it (a bare
|
||||||
|
/// filename) survives, and a person's name that some *other* module formatted
|
||||||
|
/// into a panic message survives. Both are bounded by the fact that nothing
|
||||||
|
/// here reads the catalog — see the module documentation — and the second is
|
||||||
|
/// why `MAX_MESSAGE` exists.
|
||||||
|
pub fn redact(text: &str) -> String {
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
let mut redact_next = false;
|
||||||
|
|
||||||
|
for token in text.split_inclusive(char::is_whitespace) {
|
||||||
|
// Whitespace is preserved exactly — a backtrace is read as a shape as
|
||||||
|
// much as as text — so the classification runs on the token without
|
||||||
|
// its trailing space and the space is put back.
|
||||||
|
let trailing: String = token
|
||||||
|
.chars()
|
||||||
|
.skip_while(|c| !c.is_whitespace())
|
||||||
|
.collect::<String>();
|
||||||
|
let word = &token[..token.len() - trailing.len()];
|
||||||
|
|
||||||
|
if word.is_empty() {
|
||||||
|
out.push(token.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let replacement = if redact_next {
|
||||||
|
Some("<redacted>".to_string())
|
||||||
|
} else {
|
||||||
|
classify(word)
|
||||||
|
};
|
||||||
|
redact_next = names_a_secret(word);
|
||||||
|
|
||||||
|
out.push(match replacement {
|
||||||
|
Some(r) => format!("{r}{trailing}"),
|
||||||
|
None => token.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
out.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one token should be replaced with, or `None` to keep it.
|
||||||
|
fn classify(word: &str) -> Option<String> {
|
||||||
|
// `key=value` and `key: value` written as one token. Split at the first
|
||||||
|
// separator so `Authorization:Bearer` loses the half that matters.
|
||||||
|
if let Some((head, tail)) = word.split_once(['=', ':']) {
|
||||||
|
if names_a_secret(head) && !tail.is_empty() {
|
||||||
|
return Some(format!("{head}=<redacted>"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip the punctuation a sentence puts around a path — "opening
|
||||||
|
// /home/x/y.CR3:" — so the classification sees the path itself, then put
|
||||||
|
// nothing back: the punctuation is not worth the complexity of restoring
|
||||||
|
// it around a placeholder.
|
||||||
|
let bare = word.trim_matches(|c: char| matches!(c, '"' | '\'' | '(' | ')' | ',' | ';' | ':'));
|
||||||
|
|
||||||
|
if bare.contains('/') {
|
||||||
|
// A Rust source path keeps its basename. Line and column are part of
|
||||||
|
// the same token in a backtrace ("src/library.rs:1270:9"), and they
|
||||||
|
// are kept: they are facts about this codebase.
|
||||||
|
if let Some(rs) = rust_source_tail(bare) {
|
||||||
|
return Some(format!("…/{rs}"));
|
||||||
|
}
|
||||||
|
return Some("<path>".to_string());
|
||||||
|
}
|
||||||
|
if bare.starts_with('~') {
|
||||||
|
return Some("<path>".to_string());
|
||||||
|
}
|
||||||
|
if looks_opaque(bare) {
|
||||||
|
return Some("<redacted>".to_string());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `library.rs:1270:9` tail of a path naming a Rust source file.
|
||||||
|
fn rust_source_tail(word: &str) -> Option<&str> {
|
||||||
|
let tail = word.rsplit('/').next()?;
|
||||||
|
// The extension is followed by `:line:col` in a backtrace and by nothing
|
||||||
|
// in a `Location`, so match on the extension rather than on the end.
|
||||||
|
if tail.contains(".rs") {
|
||||||
|
Some(tail)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this word introduces a value that must not be written down.
|
||||||
|
fn names_a_secret(word: &str) -> bool {
|
||||||
|
let w = word
|
||||||
|
.trim_matches(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
matches!(
|
||||||
|
w.as_str(),
|
||||||
|
"password"
|
||||||
|
| "passwd"
|
||||||
|
| "app-password"
|
||||||
|
| "app_password"
|
||||||
|
| "token"
|
||||||
|
| "secret"
|
||||||
|
| "bearer"
|
||||||
|
| "authorization"
|
||||||
|
| "apikey"
|
||||||
|
| "api-key"
|
||||||
|
| "api_key"
|
||||||
|
| "credential"
|
||||||
|
| "credentials"
|
||||||
|
| "cookie"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a word looks like a key rather than like prose.
|
||||||
|
///
|
||||||
|
/// A long run of the characters secrets are encoded in, containing both a
|
||||||
|
/// letter and a digit — which is what an app password, a bearer token or a
|
||||||
|
/// base64 blob looks like, and what an English word or a Rust identifier does
|
||||||
|
/// not.
|
||||||
|
fn looks_opaque(word: &str) -> bool {
|
||||||
|
word.len() >= 20
|
||||||
|
&& word
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '_' | '-' | '='))
|
||||||
|
&& word.chars().any(|c| c.is_ascii_digit())
|
||||||
|
&& word.chars().any(|c| c.is_ascii_alphabetic())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cut `s` to `max` bytes on a character boundary, saying that it was cut.
|
||||||
|
fn truncate(s: &str, max: usize) -> String {
|
||||||
|
if s.len() <= max {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
let mut end = max;
|
||||||
|
while end > 0 && !s.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
format!("{}… ({} bytes truncated)", &s[..end], s.len() - end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seconds since the epoch, or 0 if the clock is before it.
|
||||||
|
fn now() -> i64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_users_photograph_never_reaches_a_record() {
|
||||||
|
let r = redact("failed to open /home/anna/Photos/2019 Divorce/IMG_0042.CR3");
|
||||||
|
assert!(!r.contains("anna"), "{r}");
|
||||||
|
assert!(!r.contains("IMG_0042"), "{r}");
|
||||||
|
assert!(!r.contains("Divorce"), "{r}");
|
||||||
|
assert!(r.contains("failed to open"), "the diagnosis was lost: {r}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_android_document_uri_is_a_path_too() {
|
||||||
|
// SAF hands out `content://` URIs and `primary:DCIM/...` refs rather
|
||||||
|
// than paths, and both name the user's library just as precisely.
|
||||||
|
let r = redact("no such image: content://com.android.providers/tree/primary%3ADCIM");
|
||||||
|
assert!(!r.contains("DCIM"), "{r}");
|
||||||
|
let r = redact("source_ref primary:DCIM/Camera/IMG_1.CR3 missing");
|
||||||
|
assert!(!r.contains("IMG_1"), "{r}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_server_url_is_removed_because_it_names_the_user() {
|
||||||
|
// A Nextcloud URL is the user's own server, often with their login in
|
||||||
|
// the path. NFR-SEC-3 is about the wire; this is about the disk.
|
||||||
|
let r = redact("PROPFIND https://cloud.example.org/remote.php/dav/files/anna/ failed");
|
||||||
|
assert!(!r.contains("cloud.example.org"), "{r}");
|
||||||
|
assert!(!r.contains("anna"), "{r}");
|
||||||
|
assert!(r.contains("PROPFIND"), "{r}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_credential_never_reaches_a_record() {
|
||||||
|
// NFR-SEC-2, which forbids credentials in logs and plain files. Both
|
||||||
|
// spellings: the value beside a naming word, and the value alone.
|
||||||
|
let r = redact("auth failed: password hunter2correcthorse");
|
||||||
|
assert!(!r.contains("hunter2correcthorse"), "{r}");
|
||||||
|
|
||||||
|
let r = redact("Authorization: Bearer aGVsbG90aGVyZTEyMzQ1Njc4OTA=");
|
||||||
|
assert!(!r.contains("aGVsbG90aGVyZTEyMzQ1Njc4OTA"), "{r}");
|
||||||
|
|
||||||
|
let r = redact("rejected app-password=abcde-fghij-12345-klmno-pqrst");
|
||||||
|
assert!(!r.contains("abcde-fghij"), "{r}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bare_secret_is_caught_by_its_shape() {
|
||||||
|
// Nextcloud app passwords arrive with no label at all when they are
|
||||||
|
// interpolated into a message by a library this codebase does not own.
|
||||||
|
let r = redact("login failed for aBcDe1FgHiJ2kLmNo3PqRsT4uV");
|
||||||
|
assert!(!r.contains("aBcDe1FgHiJ"), "{r}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_backtrace_keeps_the_frames_that_make_it_readable() {
|
||||||
|
// The whole reason the `.rs` exception exists: redacting these to
|
||||||
|
// `<path>` leaves a backtrace of nothing but symbol names, and the
|
||||||
|
// line number is a fact about this codebase rather than about anyone.
|
||||||
|
let r = redact(
|
||||||
|
" 3: dr_ui::library::run_scan\n at ./ui/dr-ui/src/library.rs:1270:9\n",
|
||||||
|
);
|
||||||
|
assert!(r.contains("library.rs:1270:9"), "{r}");
|
||||||
|
assert!(r.contains("dr_ui::library::run_scan"), "{r}");
|
||||||
|
assert!(!r.contains("ui/dr-ui/src"), "{r}");
|
||||||
|
// And the shape survives, because a backtrace is read as a shape.
|
||||||
|
assert!(r.contains('\n'), "{r}");
|
||||||
|
assert!(r.starts_with(" 3:"), "{r}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ordinary_words_are_left_alone() {
|
||||||
|
// Over-redaction has a cost too: a record that says nothing is not
|
||||||
|
// safer, it is just useless.
|
||||||
|
let msg = "assertion failed: tier_desired was 3, expected 2";
|
||||||
|
assert_eq!(redact(msg), msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_enormous_payload_is_capped() {
|
||||||
|
// A panic that formatted a decoded buffer — or, the case NFR-SEC-5
|
||||||
|
// cares about, an embedding — into its message.
|
||||||
|
let huge = "9".repeat(MAX_MESSAGE * 3);
|
||||||
|
let cut = truncate(&huge, MAX_MESSAGE);
|
||||||
|
assert!(cut.len() < huge.len());
|
||||||
|
assert!(cut.contains("truncated"), "{cut}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncation_does_not_split_a_character() {
|
||||||
|
let s = "é".repeat(100);
|
||||||
|
// 3 is mid-character for a 2-byte encoding.
|
||||||
|
let cut = truncate(&s, 3);
|
||||||
|
assert!(cut.starts_with('é'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn records_are_written_and_rotated() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("dr-crash-test-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
|
||||||
|
for i in 0..(KEEP_RECORDS as i64 + 5) {
|
||||||
|
write_record(
|
||||||
|
&dir,
|
||||||
|
&Record {
|
||||||
|
body: format!("darkroom-crash 1\nmessage: {i}\n"),
|
||||||
|
summary: String::new(),
|
||||||
|
// Distinct seconds, or the pid-suffixed names would
|
||||||
|
// collide and the rotation would have nothing to count.
|
||||||
|
when: 1_000 + i,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let kept: Vec<PathBuf> = std::fs::read_dir(&dir)
|
||||||
|
.unwrap()
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.path())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(kept.len(), KEEP_RECORDS);
|
||||||
|
// The newest survive, not the oldest.
|
||||||
|
let newest = kept.iter().filter_map(|p| timestamp_of(p)).max();
|
||||||
|
let oldest = kept.iter().filter_map(|p| timestamp_of(p)).min();
|
||||||
|
assert_eq!(newest, Some(1_000 + KEEP_RECORDS as i64 + 4));
|
||||||
|
assert_eq!(oldest, Some(1_000 + 5));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stray_file_is_neither_listed_nor_pruned() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("dr-crash-stray-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
std::fs::write(dir.join("darkroom.log"), b"not a crash").unwrap();
|
||||||
|
|
||||||
|
for i in 0..(KEEP_RECORDS as i64 + 5) {
|
||||||
|
write_record(
|
||||||
|
&dir,
|
||||||
|
&Record {
|
||||||
|
body: String::new(),
|
||||||
|
summary: String::new(),
|
||||||
|
when: 2_000 + i,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(dir.join("darkroom.log").is_file(), "the log was pruned");
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_state_directory_is_neither_config_nor_cache() {
|
||||||
|
// NFR-OPS-1 puts the log here too, and NFR-OPS-3 keeps preferences
|
||||||
|
// separate from both. The distinction is what stops a crash record
|
||||||
|
// being swept away by the thing that is allowed to sweep caches.
|
||||||
|
let dir = state_dir();
|
||||||
|
let s = dir.to_string_lossy();
|
||||||
|
assert!(s.ends_with("darkroom"), "{s}");
|
||||||
|
assert!(!s.contains("/cache"), "{s}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,617 @@
|
|||||||
|
//! TRACES: NFR-OPS-1 | NFR-SEC-2
|
||||||
|
//! A log the photographer still has tomorrow.
|
||||||
|
//!
|
||||||
|
//! Until this existed, everything this application knew about a failure went
|
||||||
|
//! to stderr on the desktop and to logcat on Android, and both are gone the
|
||||||
|
//! moment the terminal closes or the ring buffer wraps. That is tolerable when
|
||||||
|
//! the person debugging is sitting at the machine. It is useless for the case
|
||||||
|
//! this module is built for and which NFR-OPS-1 describes: **somebody
|
||||||
|
//! reproduces a bug on a tablet, and then sends us a file.**
|
||||||
|
//!
|
||||||
|
//! Android makes that the *normal* case rather than an awkward one. A
|
||||||
|
//! `NativeActivity` has no console. `adb logcat` requires a cable, developer
|
||||||
|
//! mode, and — crucially — that you were already watching when it happened.
|
||||||
|
//! The failures most worth catching are the ones that are one line long and
|
||||||
|
//! scroll past: a class the activity's loader could not find, a permission
|
||||||
|
//! that was refused, a root the system revoked while the app was backgrounded.
|
||||||
|
//! A file survives all three.
|
||||||
|
//!
|
||||||
|
//! # The shape of it
|
||||||
|
//!
|
||||||
|
//! One [`LogFile`] in [`crate::state::state_dir`], teed with whatever logger
|
||||||
|
//! the platform already had rather than replacing it — logcat stays exactly as
|
||||||
|
//! it was, which matters because losing it while debugging would make this a
|
||||||
|
//! downgrade. [`install`] is the whole of the wiring.
|
||||||
|
//!
|
||||||
|
//! Three properties, each of which is a test below:
|
||||||
|
//!
|
||||||
|
//! * **Capped.** Two files of [`MAX_FILE_BYTES`], so the worst case is stated
|
||||||
|
//! rather than discovered on a full phone.
|
||||||
|
//! * **Redacted at the sink** ([`redact`]), not at the call sites. A rule
|
||||||
|
//! every author has to remember is not a rule.
|
||||||
|
//! * **Durable per line.** No `BufWriter` anywhere, which is deliberate and
|
||||||
|
//! costs a syscall per record: the line worth having is the last one before
|
||||||
|
//! the process died, and on Android processes do not die politely — the
|
||||||
|
//! system kills them, with no unwinding and no `Drop`. A buffered log loses
|
||||||
|
//! precisely the evidence it was kept for.
|
||||||
|
//!
|
||||||
|
//! # Where the file lands
|
||||||
|
//!
|
||||||
|
//! * Linux: `$XDG_STATE_HOME/darkroom/darkroom.log`, else
|
||||||
|
//! `~/.local/state/darkroom/darkroom.log`.
|
||||||
|
//! * Android: `/sdcard/Android/data/paris.tourolle.darkroom/files/darkroom.log`,
|
||||||
|
//! which `adb pull` reads from an ordinary release build. See
|
||||||
|
//! [`crate::state`] for why not the internal directory, and
|
||||||
|
//! [`redact`] for the consequence.
|
||||||
|
//!
|
||||||
|
//! # How crash capture should reach this
|
||||||
|
//!
|
||||||
|
//! It should not open its own file. A panic hook that calls `log::error!`
|
||||||
|
//! lands here like anything else, and lands *completely*, because writes are
|
||||||
|
//! unbuffered and per record — that is the property NFR-OPS-2 needs from
|
||||||
|
//! NFR-OPS-1 and the reason the two are not the same file. For a crash record
|
||||||
|
//! written separately, [`log_path`] says which log belongs with it and
|
||||||
|
//! [`redact::redact`] is public so the record gets the same treatment as the
|
||||||
|
//! lines around it.
|
||||||
|
|
||||||
|
pub mod redact;
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::fs::{self, File, OpenOptions};
|
||||||
|
use std::io::{self, Write as _};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use log::{LevelFilter, Log, Metadata, Record};
|
||||||
|
|
||||||
|
/// The current log, and — after one rotation — `darkroom.log.1` beside it.
|
||||||
|
const FILE_NAME: &str = "darkroom.log";
|
||||||
|
|
||||||
|
/// The stated cap, per file. With [`RETAINED_GENERATIONS`] the application
|
||||||
|
/// occupies at most 8 MiB of the state directory, ever.
|
||||||
|
///
|
||||||
|
/// Chosen against the retrieval, not against the disk: this is roughly forty
|
||||||
|
/// thousand lines at `info`, which is a long session's worth of context, and
|
||||||
|
/// it is small enough that `adb pull` over USB is instant and a user can
|
||||||
|
/// attach it to a message without thinking about it. A larger cap would buy
|
||||||
|
/// history nobody reads at the price of a file nobody sends.
|
||||||
|
pub const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// How many rotated files are kept behind the current one.
|
||||||
|
///
|
||||||
|
/// One. The reason to keep any is that the interesting event often precedes
|
||||||
|
/// the rotation that a burst of retry logging then caused; the reason not to
|
||||||
|
/// keep more is that the worst case has to be a number this module can state.
|
||||||
|
pub const RETAINED_GENERATIONS: usize = 1;
|
||||||
|
|
||||||
|
/// The longest one record may be before it is cut short.
|
||||||
|
///
|
||||||
|
/// A guard against a single `{:?}` on something large — a decoded buffer, a
|
||||||
|
/// face embedding, an entire WebDAV response — turning the whole cap into one
|
||||||
|
/// unreadable line and evicting everything that led up to it. Generous enough
|
||||||
|
/// that no real message reaches it.
|
||||||
|
pub const MAX_LINE_BYTES: usize = 8 * 1024;
|
||||||
|
|
||||||
|
const TRUNCATION_MARK: &str = "…[truncated]";
|
||||||
|
|
||||||
|
/// Set by [`install`] once, so crash capture can name the file without
|
||||||
|
/// re-deriving the directory and possibly disagreeing about it.
|
||||||
|
static LOG_PATH: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
|
/// What [`install`] managed, said as data rather than as a log line — because
|
||||||
|
/// at the moment it is decided there is no logger to say it with.
|
||||||
|
///
|
||||||
|
/// The caller logs it as the first line of the session, which is how the file
|
||||||
|
/// ends up naming itself to anyone reading logcat over the user's shoulder.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Installed {
|
||||||
|
/// The tee is live and this is the file to ask the user for.
|
||||||
|
ToFile(PathBuf),
|
||||||
|
/// Logging works, but only where it always did, and this is why. A
|
||||||
|
/// read-only or missing state directory is not a reason to start the
|
||||||
|
/// application without a logger.
|
||||||
|
ConsoleOnly(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where this session is logging, if it is logging to a file at all.
|
||||||
|
pub fn log_path() -> Option<PathBuf> {
|
||||||
|
LOG_PATH.get().cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the file sink alongside the logger the platform already provides.
|
||||||
|
///
|
||||||
|
/// `console` is the platform's own logger — `env_logger`'s on the desktop,
|
||||||
|
/// `android_logger`'s on a device — and it keeps receiving every record it
|
||||||
|
/// would have received. `level` is the maximum level to let through globally;
|
||||||
|
/// pass whatever the console logger was built to filter at, so the two agree.
|
||||||
|
///
|
||||||
|
/// Call it exactly once, first thing, and *after*
|
||||||
|
/// [`crate::state::set_state_dir`] on any platform that needs to declare a
|
||||||
|
/// directory.
|
||||||
|
pub fn install(console: Box<dyn Log>, level: LevelFilter) -> Installed {
|
||||||
|
let dir = crate::state::state_dir();
|
||||||
|
|
||||||
|
let (file, outcome) = match LogFile::open_in(&dir, MAX_FILE_BYTES) {
|
||||||
|
Ok(file) => {
|
||||||
|
let path = file.path().to_path_buf();
|
||||||
|
let _ = LOG_PATH.set(path.clone());
|
||||||
|
(Some(file), Installed::ToFile(path))
|
||||||
|
}
|
||||||
|
Err(e) => (
|
||||||
|
None,
|
||||||
|
Installed::ConsoleOnly(format!("{} is not writable: {e}", dir.display())),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
if log::set_boxed_logger(Box::new(Tee { console, file })).is_err() {
|
||||||
|
// Something installed a logger before us. Saying so is the only useful
|
||||||
|
// response: whatever it was is still logging, and the file is not.
|
||||||
|
return Installed::ConsoleOnly("a logger was already installed".to_string());
|
||||||
|
}
|
||||||
|
log::set_max_level(level);
|
||||||
|
outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The platform's logger and ours, both fed from every record.
|
||||||
|
struct Tee {
|
||||||
|
console: Box<dyn Log>,
|
||||||
|
file: Option<LogFile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Log for Tee {
|
||||||
|
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
|
||||||
|
self.console.enabled(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&self, record: &Record<'_>) {
|
||||||
|
// The console first, and unconditionally: it applies its own filter,
|
||||||
|
// and on Android it is the thing a developer is watching live.
|
||||||
|
self.console.log(record);
|
||||||
|
|
||||||
|
if let Some(file) = &self.file {
|
||||||
|
// The same gate the console applies, so the file is a transcript
|
||||||
|
// of logcat rather than a different log with different contents.
|
||||||
|
// It can be a slight superset — `env_logger`'s message-regex
|
||||||
|
// filter is applied inside its `log` and not by `enabled` — and a
|
||||||
|
// superset is the right way round for a file nobody is watching.
|
||||||
|
if self.console.enabled(record.metadata()) {
|
||||||
|
file.write_line(&format_record(record));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&self) {
|
||||||
|
self.console.flush();
|
||||||
|
// Nothing to do for the file: there is no buffer to flush, which is
|
||||||
|
// the point. See the module documentation.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The log file itself, with its rotation.
|
||||||
|
///
|
||||||
|
/// Cheap to construct and safe to share: every write takes a mutex, and the
|
||||||
|
/// formatting and redaction that precede it happen outside the lock, so a
|
||||||
|
/// thread logging cannot hold up an unrelated one for longer than a `write`.
|
||||||
|
pub struct LogFile {
|
||||||
|
path: PathBuf,
|
||||||
|
/// The single retained generation. Named once here rather than derived at
|
||||||
|
/// each rotation, so the two cannot drift.
|
||||||
|
previous: PathBuf,
|
||||||
|
cap: u64,
|
||||||
|
open: Mutex<Open>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Open {
|
||||||
|
file: File,
|
||||||
|
/// Bytes in the *current* file. Seeded from the file's length on open
|
||||||
|
/// rather than from zero — otherwise an application restarted often enough
|
||||||
|
/// would append past the cap forever and NFR-OPS-1's "size-capped" would
|
||||||
|
/// hold only within a single run.
|
||||||
|
written: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogFile {
|
||||||
|
/// Open (or create) the log in `dir`, creating the directory if needed.
|
||||||
|
///
|
||||||
|
/// `cap` is per file; the directory holds at most `cap * 2`. Public with
|
||||||
|
/// an explicit cap because the tests need a small one — a rotation test
|
||||||
|
/// that had to write 4 MiB would be a rotation test nobody runs.
|
||||||
|
pub fn open_in(dir: &Path, cap: u64) -> io::Result<Self> {
|
||||||
|
fs::create_dir_all(dir)?;
|
||||||
|
let path = dir.join(FILE_NAME);
|
||||||
|
let file = open_append(&path)?;
|
||||||
|
// A failure to stat a file we just opened is not worth refusing to log
|
||||||
|
// over; treating it as empty costs at most one late rotation.
|
||||||
|
let written = file.metadata().map(|m| m.len()).unwrap_or(0);
|
||||||
|
Ok(Self {
|
||||||
|
path,
|
||||||
|
previous: dir.join(format!("{FILE_NAME}.1")),
|
||||||
|
cap,
|
||||||
|
open: Mutex::new(Open { file, written }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file to ask the user to send.
|
||||||
|
pub fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Redact, cap, and append one record.
|
||||||
|
///
|
||||||
|
/// Infallible by design. A logger that panicked, or that tried to report
|
||||||
|
/// its own failure through `log`, would take the application down or
|
||||||
|
/// recurse; a log line lost to a full disk is a smaller problem than
|
||||||
|
/// either.
|
||||||
|
pub fn write_line(&self, line: &str) {
|
||||||
|
// Both before the lock: neither can block, and neither should make
|
||||||
|
// another thread's `write` wait.
|
||||||
|
let mut line = redact::redact(line);
|
||||||
|
truncate_to(&mut line, MAX_LINE_BYTES);
|
||||||
|
line.push('\n');
|
||||||
|
|
||||||
|
// A poisoned mutex means another thread panicked mid-write. The log is
|
||||||
|
// exactly what is wanted at that moment, so carry on with whatever
|
||||||
|
// state it left: the worst case is one interleaved line.
|
||||||
|
let mut open = self.open.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
|
||||||
|
let len = line.len() as u64;
|
||||||
|
if open.written > 0 && open.written + len > self.cap {
|
||||||
|
// A failed rotation is not a reason to drop the record. The only
|
||||||
|
// way `rename` fails on a directory we opened a file in is that
|
||||||
|
// the directory has gone, in which case the write below fails too
|
||||||
|
// and the line is lost either way.
|
||||||
|
let _ = self.rotate(&mut open);
|
||||||
|
}
|
||||||
|
|
||||||
|
if open.file.write_all(line.as_bytes()).is_ok() {
|
||||||
|
open.written += len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `darkroom.log` becomes `darkroom.log.1`; the previous `.1` is dropped.
|
||||||
|
///
|
||||||
|
/// Rename rather than copy-and-truncate, so a reader holding the old file
|
||||||
|
/// keeps reading a whole file and never sees it shrink under them. The old
|
||||||
|
/// descriptor stays valid against the renamed inode until it is replaced
|
||||||
|
/// on the next line.
|
||||||
|
fn rotate(&self, open: &mut Open) -> io::Result<()> {
|
||||||
|
let _ = fs::remove_file(&self.previous);
|
||||||
|
fs::rename(&self.path, &self.previous)?;
|
||||||
|
open.file = open_append(&self.path)?;
|
||||||
|
open.written = 0;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_append(path: &Path) -> io::Result<File> {
|
||||||
|
let mut options = OpenOptions::new();
|
||||||
|
options.create(true).append(true);
|
||||||
|
// The log names the user's photographs and the shape of their library,
|
||||||
|
// which is nobody else's business on a shared machine. Applied at
|
||||||
|
// creation only, which is all that is needed, and ignored by the
|
||||||
|
// FAT-derived filesystem Android presents as external storage — where the
|
||||||
|
// directory is already scoped to this application.
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::OpenOptionsExt as _;
|
||||||
|
options.mode(0o600);
|
||||||
|
}
|
||||||
|
options.open(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One record as one line.
|
||||||
|
///
|
||||||
|
/// The thread name is here and not in `env_logger`'s default format because of
|
||||||
|
/// what it costs to leave out on Android: the failure this module was written
|
||||||
|
/// to catch is a class the activity's loader will not resolve, and whether the
|
||||||
|
/// call came from `android_main`'s thread or from a worker is the entire
|
||||||
|
/// diagnosis. Unnamed threads report their id instead of nothing, so two
|
||||||
|
/// interleaved workers can still be told apart.
|
||||||
|
fn format_record(record: &Record<'_>) -> String {
|
||||||
|
let mut line = String::with_capacity(128);
|
||||||
|
push_timestamp(&mut line, SystemTime::now());
|
||||||
|
|
||||||
|
let thread = std::thread::current();
|
||||||
|
let _ = match thread.name() {
|
||||||
|
Some(name) => write!(line, " {:<5} [{name}]", record.level()),
|
||||||
|
None => write!(line, " {:<5} [{:?}]", record.level(), thread.id()),
|
||||||
|
};
|
||||||
|
let _ = write!(line, " {}: {}", record.target(), record.args());
|
||||||
|
line
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `2026-08-30T12:34:56.789Z`, in UTC.
|
||||||
|
///
|
||||||
|
/// Hand-rolled rather than reached for from a crate. `chrono` and `jiff` are
|
||||||
|
/// both already in the lock file, and pulling either into `dr-plat` would put
|
||||||
|
/// a new dependency in the Android cross-compile for twenty lines of
|
||||||
|
/// arithmetic that cannot change — which is the same reasoning the workspace
|
||||||
|
/// manifest applies to TLS, SQLite and lens profiles, at a much smaller scale.
|
||||||
|
///
|
||||||
|
/// UTC and not local time, deliberately: a log is read by somebody other than
|
||||||
|
/// the person who produced it, and a timestamp with no zone in it is a
|
||||||
|
/// timestamp that has to be guessed at. Milliseconds are there to line the
|
||||||
|
/// file up against a logcat capture of the same session.
|
||||||
|
fn push_timestamp(out: &mut String, now: SystemTime) {
|
||||||
|
let since_epoch = now.duration_since(UNIX_EPOCH).unwrap_or_default();
|
||||||
|
let secs = since_epoch.as_secs() as i64;
|
||||||
|
let millis = since_epoch.subsec_millis();
|
||||||
|
|
||||||
|
// `div_euclid`, not `/`: the truncating division would put every instant
|
||||||
|
// in the six hours before 1970 on the wrong day. A clock that has not been
|
||||||
|
// set yet reports one of them.
|
||||||
|
let (year, month, day) = civil_from_days(secs.div_euclid(86_400));
|
||||||
|
let second_of_day = secs.rem_euclid(86_400);
|
||||||
|
|
||||||
|
let _ = write!(
|
||||||
|
out,
|
||||||
|
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{millis:03}Z",
|
||||||
|
second_of_day / 3600,
|
||||||
|
(second_of_day % 3600) / 60,
|
||||||
|
second_of_day % 60,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Days since 1970-01-01 to a proleptic Gregorian date.
|
||||||
|
///
|
||||||
|
/// Howard Hinnant's `civil_from_days`, which is the algorithm every date
|
||||||
|
/// library uses underneath: it shifts the epoch to 0000-03-01 so that the
|
||||||
|
/// leap day falls at the end of the year and the 400-year cycle divides
|
||||||
|
/// evenly, which is what removes every branch.
|
||||||
|
fn civil_from_days(days: i64) -> (i64, i64, i64) {
|
||||||
|
let z = days + 719_468;
|
||||||
|
// Split out rather than written inline: `if a { x } else { y } / n` is a
|
||||||
|
// parse hazard in Rust, and getting it wrong here would be a silent
|
||||||
|
// off-by-a-century for dates before 1970 rather than a compile error.
|
||||||
|
let toward_negative_infinity = if z >= 0 { z } else { z - 146_096 };
|
||||||
|
let era = toward_negative_infinity / 146_097;
|
||||||
|
let day_of_era = z - era * 146_097;
|
||||||
|
let year_of_era =
|
||||||
|
(day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
|
||||||
|
let year = year_of_era + era * 400;
|
||||||
|
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
|
||||||
|
let month_shifted = (5 * day_of_year + 2) / 153;
|
||||||
|
let day = day_of_year - (153 * month_shifted + 2) / 5 + 1;
|
||||||
|
let month = if month_shifted < 10 {
|
||||||
|
month_shifted + 3
|
||||||
|
} else {
|
||||||
|
month_shifted - 9
|
||||||
|
};
|
||||||
|
(if month <= 2 { year + 1 } else { year }, month, day)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cut a line to `max` bytes without splitting a character in half.
|
||||||
|
fn truncate_to(line: &mut String, max: usize) {
|
||||||
|
if line.len() <= max {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut cut = max.saturating_sub(TRUNCATION_MARK.len());
|
||||||
|
while cut > 0 && !line.is_char_boundary(cut) {
|
||||||
|
cut -= 1;
|
||||||
|
}
|
||||||
|
line.truncate(cut);
|
||||||
|
line.push_str(TRUNCATION_MARK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// An empty directory of this test's own. The convention elsewhere in the
|
||||||
|
/// workspace (`dr_decode::base_curve`), and it matters more here: two
|
||||||
|
/// tests sharing a log directory would rotate each other's files.
|
||||||
|
fn a_log_dir(name: &str) -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!("darkroom-diagnostics-{name}"));
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
fs::create_dir_all(&dir).expect("a writable temp directory");
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(path: &Path) -> String {
|
||||||
|
fs::read_to_string(path).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Long enough that a handful of them cross a small cap, short enough that
|
||||||
|
/// the arithmetic in each test stays readable.
|
||||||
|
fn a_line(n: usize) -> String {
|
||||||
|
format!("{n:04} scanning /home/duncan/Pictures/2026/Iceland for images")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_log_stays_under_its_stated_cap() {
|
||||||
|
// **This is NFR-OPS-1's "size-capped".** Without rotation a phone left
|
||||||
|
// running fills its state directory and the failure lands on the user
|
||||||
|
// as "storage full", days after the logging that caused it.
|
||||||
|
let dir = a_log_dir("cap");
|
||||||
|
let cap = 4096;
|
||||||
|
let log = LogFile::open_in(&dir, cap).expect("opens");
|
||||||
|
|
||||||
|
for n in 0..500 {
|
||||||
|
log.write_line(&a_line(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = fs::metadata(dir.join(FILE_NAME))
|
||||||
|
.expect("a current log")
|
||||||
|
.len();
|
||||||
|
assert!(
|
||||||
|
current <= cap,
|
||||||
|
"the current log is {current} bytes, over {cap}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let previous = fs::metadata(dir.join(format!("{FILE_NAME}.1")))
|
||||||
|
.expect("a rotated log, since 500 lines cannot fit in 4 KiB")
|
||||||
|
.len();
|
||||||
|
assert!(
|
||||||
|
previous <= cap,
|
||||||
|
"the rotated log is {previous} bytes, over {cap}"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!dir.join(format!("{FILE_NAME}.2")).exists(),
|
||||||
|
"only {RETAINED_GENERATIONS} generation is kept; a second would make the \
|
||||||
|
worst case unstatable"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
current + previous <= cap * 2,
|
||||||
|
"the whole directory must fit in twice the cap"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rotation_keeps_the_newest_lines_and_drops_the_oldest() {
|
||||||
|
// Which half survives is the whole value of the feature: the user
|
||||||
|
// reports a bug and the interesting lines are the last ones.
|
||||||
|
let dir = a_log_dir("newest");
|
||||||
|
let log = LogFile::open_in(&dir, 4096).expect("opens");
|
||||||
|
for n in 0..500 {
|
||||||
|
log.write_line(&a_line(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = read(&dir.join(FILE_NAME));
|
||||||
|
let previous = read(&dir.join(format!("{FILE_NAME}.1")));
|
||||||
|
assert!(
|
||||||
|
current.contains(&a_line(499)),
|
||||||
|
"the last line written must be in the current log"
|
||||||
|
);
|
||||||
|
let oldest = a_line(0);
|
||||||
|
assert!(
|
||||||
|
!current.contains(&oldest) && !previous.contains(&oldest),
|
||||||
|
"the oldest lines are what a cap gives up"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_line_survives_a_process_restart() {
|
||||||
|
// The requirement in one test. Everything else about this module is an
|
||||||
|
// implementation detail of "the user reproduces it, quits, and sends
|
||||||
|
// us the file".
|
||||||
|
let dir = a_log_dir("restart");
|
||||||
|
{
|
||||||
|
let log = LogFile::open_in(&dir, MAX_FILE_BYTES).expect("opens");
|
||||||
|
log.write_line("the failure the user is about to report");
|
||||||
|
} // the process ends here
|
||||||
|
|
||||||
|
let log = LogFile::open_in(&dir, MAX_FILE_BYTES).expect("reopens");
|
||||||
|
log.write_line("the next launch");
|
||||||
|
|
||||||
|
let text = read(&dir.join(FILE_NAME));
|
||||||
|
assert!(
|
||||||
|
text.contains("the failure the user is about to report"),
|
||||||
|
"a restart truncated the log: {text}"
|
||||||
|
);
|
||||||
|
assert!(text.contains("the next launch"));
|
||||||
|
assert!(
|
||||||
|
text.find("the failure") < text.find("the next launch"),
|
||||||
|
"the second session must append, not prepend"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_cap_survives_a_restart_too() {
|
||||||
|
// The bug this exists to prevent: seeding `written` from zero on open
|
||||||
|
// rather than from the file's length. Nothing looks wrong until an
|
||||||
|
// application that restarts often has a log of unbounded size, which
|
||||||
|
// is the one failure mode a cap is for.
|
||||||
|
let dir = a_log_dir("restart-cap");
|
||||||
|
let cap = 2048;
|
||||||
|
for _ in 0..40 {
|
||||||
|
let log = LogFile::open_in(&dir, cap).expect("opens");
|
||||||
|
for n in 0..5 {
|
||||||
|
log.write_line(&a_line(n));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = fs::metadata(dir.join(FILE_NAME))
|
||||||
|
.expect("a current log")
|
||||||
|
.len();
|
||||||
|
assert!(
|
||||||
|
current <= cap,
|
||||||
|
"forty restarts appended {current} bytes into a {cap}-byte file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_credential_never_reaches_the_file() {
|
||||||
|
// **NFR-SEC-2 at the sink**, which is the placement the requirement
|
||||||
|
// depends on: the call site here has done nothing wrong that a
|
||||||
|
// reviewer would catch, and the credential still does not land.
|
||||||
|
let dir = a_log_dir("redaction");
|
||||||
|
let log = LogFile::open_in(&dir, MAX_FILE_BYTES).expect("opens");
|
||||||
|
log.write_line(r#"login response {"loginName":"duncan","appPassword":"wYh3K8mLpQr2"}"#);
|
||||||
|
log.write_line("PUT https://duncan:hunter2@cloud.example/remote.php/dav failed");
|
||||||
|
|
||||||
|
let text = read(&dir.join(FILE_NAME));
|
||||||
|
assert!(
|
||||||
|
!text.contains("wYh3K8mLpQr2"),
|
||||||
|
"an app password reached the log: {text}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!text.contains("hunter2"),
|
||||||
|
"a URL password reached the log: {text}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
text.contains("cloud.example") && text.contains("duncan"),
|
||||||
|
"the diagnostic part must survive the redaction: {text}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_enormous_record_cannot_evict_the_whole_log() {
|
||||||
|
// A `{:?}` on something large — a decoded buffer, an embedding — must
|
||||||
|
// cost a line rather than the file.
|
||||||
|
let dir = a_log_dir("truncation");
|
||||||
|
let log = LogFile::open_in(&dir, MAX_FILE_BYTES).expect("opens");
|
||||||
|
log.write_line("a line worth keeping");
|
||||||
|
log.write_line(&"x".repeat(MAX_LINE_BYTES * 4));
|
||||||
|
|
||||||
|
let text = read(&dir.join(FILE_NAME));
|
||||||
|
assert!(text.contains("a line worth keeping"));
|
||||||
|
assert!(
|
||||||
|
text.contains(TRUNCATION_MARK),
|
||||||
|
"the oversized record should have been cut short"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
text.len() < MAX_LINE_BYTES * 2,
|
||||||
|
"the oversized record was written whole: {} bytes",
|
||||||
|
text.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncation_never_splits_a_character() {
|
||||||
|
let mut line = "é".repeat(64);
|
||||||
|
truncate_to(&mut line, 40);
|
||||||
|
// The assertion is that this is still a `String` at all — the failure
|
||||||
|
// is a panic inside `truncate` on a non-boundary index — plus that it
|
||||||
|
// says it was cut.
|
||||||
|
assert!(line.ends_with(TRUNCATION_MARK));
|
||||||
|
assert!(line.len() <= 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_timestamp_is_utc_and_sortable() {
|
||||||
|
let mut out = String::new();
|
||||||
|
let an_instant = UNIX_EPOCH + std::time::Duration::from_millis(1_700_000_000_123);
|
||||||
|
push_timestamp(&mut out, an_instant);
|
||||||
|
assert_eq!(out, "2023-11-14T22:13:20.123Z");
|
||||||
|
|
||||||
|
let mut epoch = String::new();
|
||||||
|
push_timestamp(&mut epoch, UNIX_EPOCH);
|
||||||
|
assert_eq!(epoch, "1970-01-01T00:00:00.000Z");
|
||||||
|
|
||||||
|
// Lexicographic order is chronological order, which is what makes
|
||||||
|
// `sort` and a plain `grep` on a date prefix work.
|
||||||
|
assert!(epoch < out);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_calendar_handles_the_cases_that_are_usually_wrong() {
|
||||||
|
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||||
|
assert_eq!(civil_from_days(19_723), (2024, 1, 1));
|
||||||
|
// 2024 is a leap year; 2100 will not be, and the century rule is the
|
||||||
|
// one every hand-rolled calendar gets wrong.
|
||||||
|
assert_eq!(civil_from_days(19_782), (2024, 2, 29));
|
||||||
|
assert_eq!(civil_from_days(-1), (1969, 12, 31));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
//! TRACES: NFR-SEC-2
|
||||||
|
//! Taking the credentials back out of a line that already contains them.
|
||||||
|
//!
|
||||||
|
//! NFR-SEC-2 says credentials are never written to logs. NFR-OPS-1 restates it
|
||||||
|
//! as an obligation on the diagnostics path specifically — "automatic
|
||||||
|
//! redaction of credentials and tokens" — and the word doing the work is
|
||||||
|
//! *automatic*. A rule that every call site must remember is not a rule; it is
|
||||||
|
//! a hope, and it fails the first time somebody debugging a 401 writes
|
||||||
|
//! `log::debug!("{response:?}")` at two in the morning and forgets to take it
|
||||||
|
//! out. So the redaction happens once, at the sink, on the formatted line,
|
||||||
|
//! after every call site has had its say.
|
||||||
|
//!
|
||||||
|
//! On Android this is not a defence-in-depth nicety. The log lives in the
|
||||||
|
//! app's *external* directory so that `adb pull` can reach it
|
||||||
|
//! ([`crate::state`]), which means anyone holding the device can read it, and
|
||||||
|
//! a Nextcloud app password in there is a working credential for the user's
|
||||||
|
//! whole server.
|
||||||
|
//!
|
||||||
|
//! # What it looks for, and why not a regex
|
||||||
|
//!
|
||||||
|
//! A credential in a log line is nearly always adjacent to a word that names
|
||||||
|
//! it — `appPassword`, `Authorization`, `token=` — because it got there
|
||||||
|
//! through a `Debug` impl, a serialised request, or a URL. That adjacency is
|
||||||
|
//! the only reliable signal there is: the app password Nextcloud issues is an
|
||||||
|
//! opaque 72-character string with no structure to match on, so a scanner that
|
||||||
|
//! hunted for *secret-shaped* text would find every base64 blob and every
|
||||||
|
//! content hash in the file and redact those instead.
|
||||||
|
//!
|
||||||
|
//! Hence a keyword scanner, hand-rolled rather than `regex`. The workspace has
|
||||||
|
//! no regex dependency and this is not worth acquiring one for — the grammar
|
||||||
|
//! is "a known word, an `=` or a `:`, a value" plus the two forms that carry a
|
||||||
|
//! credential with no keyword at all: an `Authorization` scheme and a URL's
|
||||||
|
//! userinfo. Three rules, each of which fits in a function and has a test.
|
||||||
|
//!
|
||||||
|
//! # What it deliberately leaves alone
|
||||||
|
//!
|
||||||
|
//! **Filesystem paths, and the names of the user's own photographs.** They are
|
||||||
|
//! not in NFR-SEC-2's list, they are not in NFR-SEC-5's, and they are the
|
||||||
|
//! single most useful thing in a log about a file that would not open. A
|
||||||
|
//! diagnostics file that says "failed to decode <redacted>" is not a
|
||||||
|
//! diagnostic. The rule NFR-OPS-1 states — an explicit preview-and-consent
|
||||||
|
//! step before anything leaves the device — is the one that governs paths, and
|
||||||
|
//! it governs them better than scrubbing would, because it lets the user look.
|
||||||
|
//!
|
||||||
|
//! **Face data (NFR-SEC-5).** Not because it is permitted here, but because
|
||||||
|
//! this is the wrong instrument for it. An embedding is 512 floats; by the
|
||||||
|
//! time one has reached a formatted log line, redacting it would be a guess at
|
||||||
|
//! what a `[f32]` looks like in prose. NFR-SEC-5 makes confinement
|
||||||
|
//! *structural* — "the code path does not exist" — and the sink's contribution
|
||||||
|
//! to that is to not be one: it opens no bundle, uploads nothing, and adds no
|
||||||
|
//! new route off the device. The one thing it does do about the shape of the
|
||||||
|
//! problem is cap the line length (see [`super::MAX_LINE_BYTES`]), so a
|
||||||
|
//! `{embedding:?}` that should never have been written costs kilobytes rather
|
||||||
|
//! than megabytes and is obvious in the file rather than buried.
|
||||||
|
|
||||||
|
/// What a redacted value is replaced by. Chosen to be greppable and obviously
|
||||||
|
/// not a value: a reader finding this in a log knows something was removed,
|
||||||
|
/// which an empty string or a row of asterisks would not tell them.
|
||||||
|
pub const PLACEHOLDER: &str = "<redacted>";
|
||||||
|
|
||||||
|
/// The words that introduce a credential.
|
||||||
|
///
|
||||||
|
/// Matched case-insensitively and only on a whole-word boundary, so `password`
|
||||||
|
/// does not fire inside `passwordless` and `token` does not fire inside
|
||||||
|
/// `tokenizer`. Longer entries are not redundant with shorter ones for the
|
||||||
|
/// same reason — `apppassword` has no boundary before its `password`, so
|
||||||
|
/// without its own entry `"appPassword": "…"` would sail through, and that is
|
||||||
|
/// the exact key Nextcloud's Login Flow v2 returns the credential under
|
||||||
|
/// (`dr_sync_nextcloud::auth::AppCredentials`).
|
||||||
|
const SENSITIVE_KEYS: &[&str] = &[
|
||||||
|
"apppassword",
|
||||||
|
"app_password",
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"pwd",
|
||||||
|
"access_token",
|
||||||
|
"refresh_token",
|
||||||
|
"id_token",
|
||||||
|
"apikey",
|
||||||
|
"api_key",
|
||||||
|
"authorization",
|
||||||
|
"credentials",
|
||||||
|
"credential",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The `Authorization` schemes that carry the credential in the value rather
|
||||||
|
/// than after a keyword. `Basic dXNlcjpwdw==` is `login:password` in base64 —
|
||||||
|
/// reversible with one shell command — and every WebDAV request this app makes
|
||||||
|
/// is signed with one (`dr_sync_nextcloud`, `basic_auth`).
|
||||||
|
const AUTH_SCHEMES: &[&str] = &["basic ", "bearer "];
|
||||||
|
|
||||||
|
/// Remove anything that looks like a credential from one formatted line.
|
||||||
|
///
|
||||||
|
/// Cheap enough for the write path: one ASCII-lowercased copy and a single
|
||||||
|
/// left-to-right pass, with no allocation per match beyond the output string.
|
||||||
|
/// Non-ASCII is untouched by the lowercasing, which is what keeps the byte
|
||||||
|
/// offsets of the copy and the original identical — `to_lowercase` would not,
|
||||||
|
/// and a one-byte drift would slice a message mid-character.
|
||||||
|
pub fn redact(line: &str) -> String {
|
||||||
|
let lower = line.to_ascii_lowercase();
|
||||||
|
let low = lower.as_bytes();
|
||||||
|
|
||||||
|
let mut out = String::with_capacity(line.len());
|
||||||
|
// Everything from here to the current position is verbatim text not yet
|
||||||
|
// copied, so an unmatched line copies itself in one `push_str`.
|
||||||
|
let mut copied = 0usize;
|
||||||
|
let mut i = 0usize;
|
||||||
|
|
||||||
|
while i < low.len() {
|
||||||
|
match secret_at(low, i) {
|
||||||
|
Some((start, end)) => {
|
||||||
|
out.push_str(&line[copied..start]);
|
||||||
|
out.push_str(PLACEHOLDER);
|
||||||
|
copied = end;
|
||||||
|
i = end;
|
||||||
|
}
|
||||||
|
None => i += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push_str(&line[copied..]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The byte range of a secret beginning at or just after `i`, if there is one.
|
||||||
|
///
|
||||||
|
/// Every rule is anchored on an ASCII byte, and every boundary it returns is
|
||||||
|
/// found by scanning for ASCII delimiters — which cannot occur inside a
|
||||||
|
/// multi-byte UTF-8 sequence — so the ranges are always char boundaries even
|
||||||
|
/// though the scan is over bytes.
|
||||||
|
fn secret_at(low: &[u8], i: usize) -> Option<(usize, usize)> {
|
||||||
|
if let Some(range) = keyed_value_at(low, i) {
|
||||||
|
return Some(range);
|
||||||
|
}
|
||||||
|
if let Some(range) = bare_scheme_at(low, i) {
|
||||||
|
return Some(range);
|
||||||
|
}
|
||||||
|
url_userinfo_at(low, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `password=hunter2`, `"appPassword": "…"`, `Authorization: Basic …`.
|
||||||
|
fn keyed_value_at(low: &[u8], i: usize) -> Option<(usize, usize)> {
|
||||||
|
if i > 0 && is_word_byte(low[i - 1]) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for key in SENSITIVE_KEYS {
|
||||||
|
if !low[i..].starts_with(key.as_bytes()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let after = i + key.len();
|
||||||
|
// A whole word, so `tokenizer` is not a token and `secretary` is not
|
||||||
|
// a secret. `continue` rather than `return`: a short key failing its
|
||||||
|
// boundary test must not mask a longer one that would have passed,
|
||||||
|
// which is how `credential` would otherwise swallow `credentials`.
|
||||||
|
if low.get(after).is_some_and(|b| is_word_byte(*b)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return value_after_assignment(low, after);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value introduced by an `=` or a `:` following a key.
|
||||||
|
///
|
||||||
|
/// Requiring one of those two is what keeps prose out of it. Plenty of log
|
||||||
|
/// lines say the word — "password rejected", "no token yet" — and a rule that
|
||||||
|
/// redacted the next word after any mention of a credential would make those
|
||||||
|
/// lines unreadable while protecting nothing.
|
||||||
|
fn value_after_assignment(low: &[u8], mut j: usize) -> Option<(usize, usize)> {
|
||||||
|
j = skip_spaces(low, j);
|
||||||
|
// The closing quote of a quoted key: `"password": "…"`.
|
||||||
|
if matches!(low.get(j), Some(b'"' | b'\'')) {
|
||||||
|
j = skip_spaces(low, j + 1);
|
||||||
|
}
|
||||||
|
if !matches!(low.get(j), Some(b'=' | b':')) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
j = skip_spaces(low, j + 1);
|
||||||
|
|
||||||
|
// An opening quote is stepped over rather than redacted, so the result is
|
||||||
|
// still shaped like what it replaced: `"password": "<redacted>"` reads as
|
||||||
|
// a redacted field, `"password": <redacted>` reads as damage.
|
||||||
|
let quote = match low.get(j) {
|
||||||
|
Some(&q) if q == b'"' || q == b'\'' => {
|
||||||
|
j += 1;
|
||||||
|
Some(q)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// `Authorization: Basic dXNlcjpwdw==` — keep the scheme, take the rest.
|
||||||
|
// Which scheme was in use is diagnostic (a request signed as `Bearer`
|
||||||
|
// where the app only ever issues `Basic` is a bug worth seeing) and it is
|
||||||
|
// not itself a secret.
|
||||||
|
for scheme in AUTH_SCHEMES {
|
||||||
|
if low[j..].starts_with(scheme.as_bytes()) {
|
||||||
|
j = skip_spaces(low, j + scheme.len());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = j;
|
||||||
|
let end = match quote {
|
||||||
|
Some(q) => low[start..]
|
||||||
|
.iter()
|
||||||
|
.position(|b| *b == q)
|
||||||
|
.map_or(low.len(), |n| start + n),
|
||||||
|
None => value_end(low, start),
|
||||||
|
};
|
||||||
|
|
||||||
|
// `password=` with nothing after it has nothing to hide, and replacing an
|
||||||
|
// empty value would only make the line say less.
|
||||||
|
(end > start).then_some((start, end))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Basic dXNlcjpwdw==` on its own, with no keyword in front of it — a header
|
||||||
|
/// dumped by its value, or a `curl` line pasted into a message.
|
||||||
|
fn bare_scheme_at(low: &[u8], i: usize) -> Option<(usize, usize)> {
|
||||||
|
if i > 0 && is_word_byte(low[i - 1]) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let scheme = AUTH_SCHEMES
|
||||||
|
.iter()
|
||||||
|
.find(|scheme| low[i..].starts_with(scheme.as_bytes()))?;
|
||||||
|
let start = skip_spaces(low, i + scheme.len());
|
||||||
|
let end = value_end(low, start);
|
||||||
|
// Unlike the keyed rule, nothing here has established that a credential
|
||||||
|
// was intended — and `basic` is an ordinary English word. "using basic
|
||||||
|
// sRGB as the fallback" must survive, so the token itself has to look the
|
||||||
|
// part.
|
||||||
|
looks_like_a_credential(&low[start..end]).then_some((start, end))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an unkeyed token is credential-shaped.
|
||||||
|
///
|
||||||
|
/// Base64 is long and drawn from a fixed alphabet; a word of prose is neither.
|
||||||
|
/// Sixteen bytes is comfortably below the shortest thing that could be a real
|
||||||
|
/// `Basic` credential — base64 of `a:b` is already 8, and a Nextcloud app
|
||||||
|
/// password is 72 — and comfortably above the words that follow "basic" in a
|
||||||
|
/// sentence.
|
||||||
|
fn looks_like_a_credential(token: &[u8]) -> bool {
|
||||||
|
token.len() >= 16
|
||||||
|
&& token.iter().all(|b| {
|
||||||
|
b.is_ascii_alphanumeric() || matches!(*b, b'+' | b'/' | b'=' | b'-' | b'_' | b'.')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The password in `https://duncan:hunter2@cloud.example/remote.php/dav`.
|
||||||
|
///
|
||||||
|
/// URLs are logged constantly and this form survives every keyword rule, since
|
||||||
|
/// the credential is punctuation-delimited rather than named. The host and the
|
||||||
|
/// login are left in place: which server failed, and as whom, is the substance
|
||||||
|
/// of a sync bug report.
|
||||||
|
fn url_userinfo_at(low: &[u8], i: usize) -> Option<(usize, usize)> {
|
||||||
|
if !low[i..].starts_with(b"://") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let authority = i + 3;
|
||||||
|
// The authority ends at the path, the query, the fragment, or whatever
|
||||||
|
// punctuation the surrounding prose used to end the URL.
|
||||||
|
let end = low[authority..]
|
||||||
|
.iter()
|
||||||
|
.position(|b| matches!(*b, b'/' | b'?' | b'#') || is_value_delimiter(*b))
|
||||||
|
.map_or(low.len(), |n| authority + n);
|
||||||
|
|
||||||
|
// The *last* `@`, because a userinfo may legally contain a percent-encoded
|
||||||
|
// one and the host may not.
|
||||||
|
let at = authority + low[authority..end].iter().rposition(|b| *b == b'@')?;
|
||||||
|
let colon = authority + low[authority..at].iter().position(|b| *b == b':')?;
|
||||||
|
|
||||||
|
(at > colon + 1).then_some((colon + 1, at))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip_spaces(low: &[u8], mut j: usize) -> usize {
|
||||||
|
while matches!(low.get(j), Some(b' ' | b'\t')) {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
j
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where an unquoted value stops.
|
||||||
|
fn value_end(low: &[u8], start: usize) -> usize {
|
||||||
|
low[start..]
|
||||||
|
.iter()
|
||||||
|
.position(|b| is_value_delimiter(*b))
|
||||||
|
.map_or(low.len(), |n| start + n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The punctuation that ends an unquoted value in every form this sees: JSON
|
||||||
|
/// (`,` `}` `]` `"`), a query string (`&` `;`), a `Debug` impl (`,` `)` `}`),
|
||||||
|
/// and prose (whitespace).
|
||||||
|
fn is_value_delimiter(b: u8) -> bool {
|
||||||
|
matches!(
|
||||||
|
b,
|
||||||
|
b' ' | b'\t'
|
||||||
|
| b'\r'
|
||||||
|
| b'\n'
|
||||||
|
| b'"'
|
||||||
|
| b'\''
|
||||||
|
| b','
|
||||||
|
| b';'
|
||||||
|
| b'&'
|
||||||
|
| b')'
|
||||||
|
| b']'
|
||||||
|
| b'}'
|
||||||
|
| b'>'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What counts as "inside a word" for the boundary test. `_` is included so
|
||||||
|
/// that `access_token` is one word rather than two, which is what stops the
|
||||||
|
/// `token` rule from firing in the middle of it and redacting from there.
|
||||||
|
fn is_word_byte(b: u8) -> bool {
|
||||||
|
b.is_ascii_alphanumeric() || b == b'_'
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The strongest form of the assertion: not "the line changed" but "the
|
||||||
|
/// secret is gone". A rule that redacted the wrong span would pass a
|
||||||
|
/// weaker test.
|
||||||
|
fn assert_gone(secret: &str, line: &str) -> String {
|
||||||
|
let out = redact(line);
|
||||||
|
assert!(
|
||||||
|
!out.contains(secret),
|
||||||
|
"redaction left the credential behind\n in: {line}\n out: {out}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.contains(PLACEHOLDER),
|
||||||
|
"nothing was marked as removed: {out}"
|
||||||
|
);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_credential_login_flow_returns_never_reaches_the_file() {
|
||||||
|
// Verbatim the shape of `AppCredentials` as serde writes it — the one
|
||||||
|
// credential this application actually holds (FR-NC-1).
|
||||||
|
let out = assert_gone(
|
||||||
|
"wYh3K8mLpQr2",
|
||||||
|
r#"got {"server":"https://cloud.example","loginName":"duncan","appPassword":"wYh3K8mLpQr2"}"#,
|
||||||
|
);
|
||||||
|
// The rest of the response is the diagnostic, and it survives.
|
||||||
|
assert!(out.contains("cloud.example"));
|
||||||
|
assert!(out.contains("duncan"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_debug_printed_credential_struct_is_scrubbed() {
|
||||||
|
// The two-in-the-morning case: `log::debug!("{creds:?}")`.
|
||||||
|
assert_gone(
|
||||||
|
"wYh3K8mLpQr2",
|
||||||
|
r#"AppCredentials { server: "https://cloud.example", login_name: "duncan", app_password: "wYh3K8mLpQr2" }"#,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_basic_auth_header_keeps_its_scheme_and_loses_its_secret() {
|
||||||
|
let out = assert_gone(
|
||||||
|
"ZHVuY2FuOmh1bnRlcjI=",
|
||||||
|
"PROPFIND /remote.php/dav authorization: Basic ZHVuY2FuOmh1bnRlcjI=",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.contains("Basic"),
|
||||||
|
"which scheme was used is diagnostic, and is not the secret: {out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bearer_token_with_no_keyword_in_front_of_it_still_goes() {
|
||||||
|
assert_gone(
|
||||||
|
"eyJhbGciOiJIUzI1NiJ9",
|
||||||
|
"sending Bearer eyJhbGciOiJIUzI1NiJ9",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_password_in_a_url_goes_and_the_server_stays() {
|
||||||
|
let out = assert_gone(
|
||||||
|
"hunter2",
|
||||||
|
"PUT https://duncan:hunter2@cloud.example/remote.php/dav/files/x.cr2 failed",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.contains("duncan") && out.contains("cloud.example"),
|
||||||
|
"which server, and as whom, is the whole of a sync bug report: {out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_query_string_token_goes() {
|
||||||
|
assert_gone(
|
||||||
|
"abc123def",
|
||||||
|
"polling https://cloud.example/login/v2/poll?token=abc123def&x=1",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_word_alone_is_not_a_secret() {
|
||||||
|
// The over-redaction failure, which is how a scrubber makes a log
|
||||||
|
// useless without ever being caught: these lines say nothing secret
|
||||||
|
// and must come through untouched.
|
||||||
|
for line in [
|
||||||
|
"password rejected by the server",
|
||||||
|
"no token yet; still polling",
|
||||||
|
"the tokenizer produced 41 tokens",
|
||||||
|
"authorization failed with 401",
|
||||||
|
"secretary@cloud.example added",
|
||||||
|
// The unkeyed `Basic ` rule has no keyword to justify itself, and
|
||||||
|
// `basic` is an ordinary word. This is the line it must not eat.
|
||||||
|
"using basic sRGB as the fallback",
|
||||||
|
"bearer of the news: 12 images",
|
||||||
|
] {
|
||||||
|
assert_eq!(redact(line), line, "over-redacted: {line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_photographs_path_is_not_a_secret() {
|
||||||
|
// Stated as a test because it is a design decision and not an
|
||||||
|
// oversight: NFR-SEC-2 lists credentials, and a log that cannot name
|
||||||
|
// the file that failed to decode is not a diagnostic. See the module
|
||||||
|
// documentation.
|
||||||
|
let line = "decode failed: /home/duncan/Pictures/2026/Iceland/DSC_0431.NEF";
|
||||||
|
assert_eq!(redact(line), line);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn several_secrets_in_one_line_all_go() {
|
||||||
|
let out = redact(r#"{"password":"a1b2c3","token":"d4e5f6","user":"duncan"}"#);
|
||||||
|
assert!(!out.contains("a1b2c3"), "{out}");
|
||||||
|
assert!(!out.contains("d4e5f6"), "{out}");
|
||||||
|
assert!(out.contains("duncan"), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redaction_keeps_the_line_shaped_like_what_it_replaced() {
|
||||||
|
assert_eq!(
|
||||||
|
redact(r#"{"password": "hunter2"}"#),
|
||||||
|
r#"{"password": "<redacted>"}"#
|
||||||
|
);
|
||||||
|
assert_eq!(redact("password=hunter2"), "password=<redacted>");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_line_with_no_secret_survives_byte_for_byte() {
|
||||||
|
// Including non-ASCII, which is the case the ASCII lowercasing exists
|
||||||
|
// to protect: a `to_lowercase` here would shift byte offsets under a
|
||||||
|
// Turkish dotted capital and slice the next character in half.
|
||||||
|
for line in [
|
||||||
|
"opened /home/duncan/Bilder/Ölandsbron/DSC_0431.NEF",
|
||||||
|
"İSTANBUL library scanned: 12 034 images",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert_eq!(redact(line), line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_url_without_a_credential_is_left_whole() {
|
||||||
|
let line = "GET https://cloud.example/remote.php/dav/files/duncan/";
|
||||||
|
assert_eq!(redact(line), line);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_email_address_is_not_a_url_userinfo() {
|
||||||
|
let line = "sharing with duncan@tourolle.paris";
|
||||||
|
assert_eq!(redact(line), line);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,21 @@
|
|||||||
//! construction, so `core/` contains no `#[cfg(target_os)]` (NFR-PORT-1,
|
//! construction, so `core/` contains no `#[cfg(target_os)]` (NFR-PORT-1,
|
||||||
//! ARCH §10).
|
//! ARCH §10).
|
||||||
|
|
||||||
|
// Neither of the next two is a trait, and they are the modules here that are
|
||||||
|
// not. They belong in this crate for the same reason the traits do: "where does
|
||||||
|
// this platform let an application keep state" is a platform question, and both
|
||||||
|
// entry points need the answer before either has a window. Resolving it in
|
||||||
|
// `apps/` would mean writing it twice, once per platform, which is the
|
||||||
|
// arrangement this crate exists to prevent.
|
||||||
|
pub mod crash;
|
||||||
|
pub mod diagnostics;
|
||||||
pub mod display;
|
pub mod display;
|
||||||
pub mod secrets;
|
pub mod secrets;
|
||||||
|
pub mod state;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod volumes;
|
pub mod volumes;
|
||||||
|
|
||||||
|
pub use diagnostics::{Installed, LogFile};
|
||||||
pub use display::{
|
pub use display::{
|
||||||
Bounds, DisplayInfo, DisplayProfile, DisplayServer, DisplaySurvey, FallbackReason,
|
Bounds, DisplayInfo, DisplayProfile, DisplayServer, DisplaySurvey, FallbackReason,
|
||||||
ProfileSource,
|
ProfileSource,
|
||||||
@@ -16,6 +26,7 @@ pub use display::{
|
|||||||
pub use secrets::{
|
pub use secrets::{
|
||||||
EphemeralSecretStore, PlatformSecretStore, SecretError, SecretKind, SecretRef, SecretStore,
|
EphemeralSecretStore, PlatformSecretStore, SecretError, SecretKind, SecretRef, SecretStore,
|
||||||
};
|
};
|
||||||
|
pub use state::{set_state_dir, state_dir};
|
||||||
pub use storage::{
|
pub use storage::{
|
||||||
DirRef, Entry, LocalStorage, NewFile, Node, SeekableRead, Storage, StorageError,
|
DirRef, Entry, LocalStorage, NewFile, Node, SeekableRead, Storage, StorageError,
|
||||||
WritableStorage,
|
WritableStorage,
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
//! TRACES: NFR-OPS-1
|
||||||
|
//! Where this platform lets the application keep notes about itself.
|
||||||
|
//!
|
||||||
|
//! Not the catalog, not the photographs, not the user's configuration — those
|
||||||
|
//! three already have homes (`dr_sync::account::config_dir`,
|
||||||
|
//! `dr_ui::library::data_root`, and the sidecars beside the images). This is
|
||||||
|
//! the fourth thing: the diagnostic residue an application leaves so that a
|
||||||
|
//! failure yesterday can be read about today. A log, and — when the crash
|
||||||
|
//! path lands — a crash record.
|
||||||
|
//!
|
||||||
|
//! # Why it is its own directory and not one of the other three
|
||||||
|
//!
|
||||||
|
//! XDG separates them for a reason that is not tidiness. `$XDG_CONFIG_HOME`
|
||||||
|
//! is what the user has chosen and would miss; `$XDG_DATA_HOME` is what the
|
||||||
|
//! application built and would have to rebuild; `$XDG_STATE_HOME` is
|
||||||
|
//! "state that should persist between restarts but is not important or
|
||||||
|
//! portable enough for the data directory" — which is exactly a log file. It
|
||||||
|
//! is also the directory nobody backs up, and a log is the one file here we
|
||||||
|
//! actively want a user to be able to delete without consequence.
|
||||||
|
//!
|
||||||
|
//! # Android has none of those variables, so the platform must say
|
||||||
|
//!
|
||||||
|
//! There is no `$HOME` on Android and no XDG anything (ARCH §6.9), so the
|
||||||
|
//! guesses below resolve to a path the app cannot write. `dr_sync::account`
|
||||||
|
//! learned this the expensive way — the session list went to a doomed path,
|
||||||
|
//! nothing failed loudly, and backgrounding the app lost the sign-in — and the
|
||||||
|
//! shape of the fix is copied here deliberately: the platform entry point
|
||||||
|
//! declares the directory once, before anything opens a file in it.
|
||||||
|
//!
|
||||||
|
//! **Which Android directory is a diagnostics decision, and it belongs to the
|
||||||
|
//! caller.** `AndroidApp` offers two, and they differ in precisely the way
|
||||||
|
//! that matters here:
|
||||||
|
//!
|
||||||
|
//! * `internal_data_path` — `/data/data/<pkg>/files`. Private, durable, and
|
||||||
|
//! unreachable: pulling a file out of it needs `run-as` against a debuggable
|
||||||
|
//! build, or root.
|
||||||
|
//! * `external_data_path` — `/sdcard/Android/data/<pkg>/files`. Same lifetime
|
||||||
|
//! (the system deletes it with the app, not under storage pressure — that is
|
||||||
|
//! the *cache* directory), needs no permission since API 19, and `adb pull`
|
||||||
|
//! reads it from any build.
|
||||||
|
//!
|
||||||
|
//! A log nobody can retrieve is not a diagnostic, so `darkroom-android` points
|
||||||
|
//! this at the external one. That choice has a consequence, and it is the
|
||||||
|
//! reason [`crate::diagnostics`] redacts at the sink rather than trusting call
|
||||||
|
//! sites: everything written here is readable by anyone holding the device.
|
||||||
|
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// Declared once by the platform entry point; a guess otherwise.
|
||||||
|
static STATE_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Declare where this platform keeps application state.
|
||||||
|
///
|
||||||
|
/// Call it before anything opens a file — installing the logger is normally
|
||||||
|
/// the very next line — because a later call is *ignored* rather than obeyed.
|
||||||
|
/// That is deliberate: two callers disagreeing about the directory would
|
||||||
|
/// otherwise split the log across two files depending on which ran first, and
|
||||||
|
/// a silently-ignored second call leaves one log rather than two halves.
|
||||||
|
///
|
||||||
|
/// Desktop needs no call. The XDG resolution below is correct there.
|
||||||
|
pub fn set_state_dir(dir: PathBuf) {
|
||||||
|
let _ = STATE_DIR.set(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The directory this application's state belongs in.
|
||||||
|
///
|
||||||
|
/// It is not created here. Whoever writes into it creates it, so that merely
|
||||||
|
/// asking the question leaves nothing behind on a machine that never logs.
|
||||||
|
pub fn state_dir() -> PathBuf {
|
||||||
|
if let Some(dir) = STATE_DIR.get() {
|
||||||
|
return dir.clone();
|
||||||
|
}
|
||||||
|
xdg_state_dir(std::env::var_os("XDG_STATE_HOME"), std::env::var_os("HOME"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The XDG resolution, as a function of its inputs rather than of the process
|
||||||
|
/// environment, so it can be tested without `set_var` racing every other test
|
||||||
|
/// in the binary.
|
||||||
|
///
|
||||||
|
/// Relative values are ignored rather than resolved against the working
|
||||||
|
/// directory: the base-directory specification says so explicitly, and the
|
||||||
|
/// alternative is a `darkroom/` directory appearing wherever the app was
|
||||||
|
/// launched from.
|
||||||
|
fn xdg_state_dir(xdg_state_home: Option<OsString>, home: Option<OsString>) -> PathBuf {
|
||||||
|
xdg_state_home
|
||||||
|
.filter(|value| Path::new(value).is_absolute())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| {
|
||||||
|
home.filter(|value| Path::new(value).is_absolute())
|
||||||
|
.map(|value| PathBuf::from(value).join(".local/state"))
|
||||||
|
})
|
||||||
|
// A container or a systemd unit with neither variable set. Writing a
|
||||||
|
// log into `/tmp` is a poor outcome; refusing to log at all, on the
|
||||||
|
// one kind of machine nobody is sitting in front of, is a worse one.
|
||||||
|
.unwrap_or_else(std::env::temp_dir)
|
||||||
|
.join("darkroom")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_log_goes_under_the_state_directory_the_user_named() {
|
||||||
|
// NFR-OPS-1 says "the XDG state directory", and honouring
|
||||||
|
// $XDG_STATE_HOME is the whole of what that means to a user who has
|
||||||
|
// moved theirs.
|
||||||
|
let dir = xdg_state_dir(Some("/var/lib/dr".into()), Some("/home/someone".into()));
|
||||||
|
assert_eq!(dir, PathBuf::from("/var/lib/dr/darkroom"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn without_the_variable_it_is_the_specifications_default() {
|
||||||
|
let dir = xdg_state_dir(None, Some("/home/someone".into()));
|
||||||
|
assert_eq!(dir, PathBuf::from("/home/someone/.local/state/darkroom"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_relative_value_is_ignored_rather_than_resolved() {
|
||||||
|
// The specification requires this, and the failure it prevents is a
|
||||||
|
// `darkroom/` directory appearing in whatever the working directory
|
||||||
|
// happened to be — including, on a desktop launcher, `/`.
|
||||||
|
let dir = xdg_state_dir(Some("state".into()), Some("/home/someone".into()));
|
||||||
|
assert_eq!(dir, PathBuf::from("/home/someone/.local/state/darkroom"));
|
||||||
|
|
||||||
|
let dir = xdg_state_dir(Some("".into()), Some("".into()));
|
||||||
|
assert!(
|
||||||
|
dir.is_absolute(),
|
||||||
|
"an empty HOME must not produce a relative state directory"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_declared_directory_is_declared_once() {
|
||||||
|
// The property the entry points rely on: two callers cannot split the
|
||||||
|
// log in half. Exercised on a fresh `OnceLock` rather than the global
|
||||||
|
// one, which any other test in this binary may already have set.
|
||||||
|
let cell: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
assert!(cell.set(PathBuf::from("/first")).is_ok());
|
||||||
|
assert!(cell.set(PathBuf::from("/second")).is_err());
|
||||||
|
assert_eq!(cell.get(), Some(&PathBuf::from("/first")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@ impl Volume {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-CAT-10 | FR-PLAT-AND-1 | NFR-PORT-1
|
/// TRACES: FR-CAT-10 | NFR-PORT-1
|
||||||
/// Whether an import can reach a card on this platform at all.
|
/// Whether an import can reach a card on this platform at all.
|
||||||
///
|
///
|
||||||
/// **Not the same question as "did [`volumes`] find anything".** An empty list
|
/// **Not the same question as "did [`volumes`] find anything".** An empty list
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[package]
|
||||||
|
name = "dr-bench"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
# The suite deliberately depends on no GPU crate and no UI toolkit.
|
||||||
|
#
|
||||||
|
# That is not tidiness, it is what makes the CI job affordable: `cargo build
|
||||||
|
# --release -p dr-bench` resolves the catalog, the decoder, the thumbnail store
|
||||||
|
# and the encoder, and stops there. Adding `dr-gpu` would pull wgpu and naga
|
||||||
|
# into a job that has no adapter to use them with, and adding `dr-ui` would
|
||||||
|
# pull Slint. The GPU half of the suite is `dr-gpu`'s own frame-budget test,
|
||||||
|
# which already exists and already skips itself where there is no device — see
|
||||||
|
# `docs/benchmarks.md`.
|
||||||
|
[dependencies]
|
||||||
|
dr-types.workspace = true
|
||||||
|
dr-catalog.workspace = true
|
||||||
|
dr-thumbs.workspace = true
|
||||||
|
dr-decode.workspace = true
|
||||||
|
dr-export.workspace = true
|
||||||
|
rusqlite.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
env_logger.workspace = true
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
//! The committed numbers, and what counts as a regression against them.
|
||||||
|
//!
|
||||||
|
//! `docs/frame-budget.md` commits its measurements by hand and says why: *"a
|
||||||
|
//! regression should be a diff rather than somebody's memory."* This is the
|
||||||
|
//! same idea in a form a program can read, because §8 asks for more than a
|
||||||
|
//! record — *"a regression beyond stated tolerance fails the build"*.
|
||||||
|
//!
|
||||||
|
//! # Two gates, and they are not the same gate
|
||||||
|
//!
|
||||||
|
//! **The budget** is the requirement's own number: 2 s to open a catalog, 100
|
||||||
|
//! images a second through the preview path. It does not move. A build that
|
||||||
|
//! violates it has violated a requirement, and no amount of "but it was always
|
||||||
|
//! like that" changes it.
|
||||||
|
//!
|
||||||
|
//! **The baseline** is what this machine last measured. It moves — deliberately
|
||||||
|
//! and by hand, through `dr-bench record` — and its job is to catch the change
|
||||||
|
//! that is still inside the budget but has halved the headroom. Most real
|
||||||
|
//! performance rot arrives that way: never over the line, always a little
|
||||||
|
//! worse, until one day the line is crossed by a change that was not the cause.
|
||||||
|
//!
|
||||||
|
//! # Why a machine-sensitive metric skips its budget off the reference desktop
|
||||||
|
//!
|
||||||
|
//! §8 names *"the reference desktop"*, not CI, and it is right to. A container
|
||||||
|
//! with two cores cannot speak to a throughput target written for a
|
||||||
|
//! twenty-four-thread machine, and asserting it there would produce exactly
|
||||||
|
//! what `core/dr-gpu/tests/frame_budget.rs` refused to produce: *"a red suite
|
||||||
|
//! that everyone learns to ignore"*. So a metric declares whether its budget
|
||||||
|
//! is machine-sensitive. Those budgets are asserted under `--reference` and
|
||||||
|
//! reported everywhere else; the ones with orders of magnitude of headroom —
|
||||||
|
//! catalog open against two seconds — are asserted everywhere, because a
|
||||||
|
//! failure there is a real failure on any machine.
|
||||||
|
//!
|
||||||
|
//! # Why the committed file starts with no numbers in it
|
||||||
|
//!
|
||||||
|
//! Because nobody had run it yet. Writing plausible-looking figures into a
|
||||||
|
//! baseline is the one thing that would make the whole suite worthless: every
|
||||||
|
//! later comparison would be against a guess, and the first genuine regression
|
||||||
|
//! would be invisible or, worse, a fabricated improvement. `recorded` is
|
||||||
|
//! therefore `null` until somebody runs `dr-bench record --reference` on the
|
||||||
|
//! reference desktop and commits the diff. Until then the budget gate works
|
||||||
|
//! and the regression gate says so rather than pretending.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::fixture::Stamp;
|
||||||
|
|
||||||
|
/// Which way is better.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Direction {
|
||||||
|
LowerIsBetter,
|
||||||
|
HigherIsBetter,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One measured quantity: what it is, what it must be, and what it was.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Metric {
|
||||||
|
/// The requirement ID this speaks to, or a note saying it speaks to only
|
||||||
|
/// part of one. Free text on purpose: several of these describe a fraction
|
||||||
|
/// of a requirement, and a bare ID here would read as the whole of it.
|
||||||
|
pub requirement: String,
|
||||||
|
/// One sentence a reader of the JSON can understand without the code.
|
||||||
|
pub what: String,
|
||||||
|
pub unit: String,
|
||||||
|
pub direction: Direction,
|
||||||
|
/// Whether the budget below is a statement about a machine as much as
|
||||||
|
/// about the code. See this module's header.
|
||||||
|
pub machine_sensitive: bool,
|
||||||
|
/// The requirement's own threshold, where it has one this can check.
|
||||||
|
pub budget: Option<f64>,
|
||||||
|
/// What the last `record` measured. `null` until one has been taken.
|
||||||
|
pub recorded: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The committed file.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Baseline {
|
||||||
|
/// Prose for whoever opens the JSON first. Kept in the file rather than
|
||||||
|
/// only in `docs/benchmarks.md`, because the person who finds this in a
|
||||||
|
/// failing CI log is not reading the docs directory at that moment.
|
||||||
|
#[serde(rename = "_readme")]
|
||||||
|
pub readme: Vec<String>,
|
||||||
|
/// How much worse than [`Metric::recorded`] a metric may get before the
|
||||||
|
/// build fails, as a fraction. 0.15 is fifteen per cent.
|
||||||
|
pub tolerance: f64,
|
||||||
|
/// The machine the recorded figures came from, as [`machine_id`] spells
|
||||||
|
/// it. Compared before a regression is judged: drift against a different
|
||||||
|
/// machine's numbers is not a regression, it is a different machine.
|
||||||
|
pub recorded_on: Option<String>,
|
||||||
|
/// Unix seconds. An integer rather than a formatted date because this
|
||||||
|
/// workspace has no date library and adding one for a comment would be a
|
||||||
|
/// poor trade.
|
||||||
|
pub recorded_at_unix: Option<i64>,
|
||||||
|
/// The fixture the recorded figures describe. A number measured against a
|
||||||
|
/// different workload is not comparable, and this is what says so.
|
||||||
|
pub fixture: Option<Stamp>,
|
||||||
|
pub metrics: BTreeMap<String, Metric>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a measurement compares.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Judgement {
|
||||||
|
/// Past the requirement's own threshold. A build failure.
|
||||||
|
pub over_budget: bool,
|
||||||
|
/// Worse than the recorded baseline by more than the tolerance. A build
|
||||||
|
/// failure.
|
||||||
|
pub regressed: bool,
|
||||||
|
/// Fractional change against the baseline, positive meaning worse.
|
||||||
|
pub drift: Option<f64>,
|
||||||
|
/// A budget exists but was not asserted, because it is machine-sensitive
|
||||||
|
/// and this is not the reference desktop.
|
||||||
|
pub budget_deferred: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a judgement is made in the light of.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Judging {
|
||||||
|
/// This run declares itself the reference desktop.
|
||||||
|
pub reference: bool,
|
||||||
|
/// This run is on the machine the baseline was recorded on.
|
||||||
|
pub same_machine: bool,
|
||||||
|
pub tolerance: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Metric {
|
||||||
|
/// Judge `measured` against the budget and the baseline.
|
||||||
|
pub fn judge(&self, measured: f64, cx: Judging) -> Judgement {
|
||||||
|
let violates = |threshold: f64| match self.direction {
|
||||||
|
Direction::LowerIsBetter => measured > threshold,
|
||||||
|
Direction::HigherIsBetter => measured < threshold,
|
||||||
|
};
|
||||||
|
let assert_budget = self.budget.is_some() && (cx.reference || !self.machine_sensitive);
|
||||||
|
|
||||||
|
let drift = match self.recorded {
|
||||||
|
// A recorded zero would divide by nothing, and a recorded figure
|
||||||
|
// of zero is a broken record rather than a very fast one.
|
||||||
|
Some(was) if was > 0.0 => Some(match self.direction {
|
||||||
|
Direction::LowerIsBetter => (measured - was) / was,
|
||||||
|
Direction::HigherIsBetter => (was - measured) / was,
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Judgement {
|
||||||
|
over_budget: assert_budget && self.budget.is_some_and(violates),
|
||||||
|
regressed: cx.same_machine && drift.is_some_and(|d| d > cx.tolerance),
|
||||||
|
drift,
|
||||||
|
budget_deferred: self.budget.is_some() && !assert_budget,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Baseline {
|
||||||
|
pub fn load(path: &Path) -> Result<Baseline> {
|
||||||
|
let text = std::fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("reading the baseline at {}", path.display()))?;
|
||||||
|
serde_json::from_str(&text)
|
||||||
|
.with_context(|| format!("parsing the baseline at {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, path: &Path) -> Result<()> {
|
||||||
|
let mut text = serde_json::to_string_pretty(self)?;
|
||||||
|
// A trailing newline, so the file is a well-behaved text file and a
|
||||||
|
// `record` that changed nothing produces an empty diff.
|
||||||
|
text.push('\n');
|
||||||
|
std::fs::write(path, text)
|
||||||
|
.with_context(|| format!("writing the baseline to {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the committed baseline lives, found the way `tools/traceability`
|
||||||
|
/// finds the repo root: by walking up from this crate's manifest until
|
||||||
|
/// `docs/requirements.md` appears.
|
||||||
|
pub fn default_path() -> Result<PathBuf> {
|
||||||
|
let mut dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
while !dir.join("docs/requirements.md").exists() {
|
||||||
|
if !dir.pop() {
|
||||||
|
anyhow::bail!("could not locate the repo root above this crate");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(dir.join("docs/bench-baseline.json"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How this machine is named in the baseline.
|
||||||
|
///
|
||||||
|
/// Host name plus thread count. Not a hardware inventory — it exists to answer
|
||||||
|
/// one question, "are these numbers from here?", and to answer it the same way
|
||||||
|
/// twice on the same box. A container whose hostname changes per run therefore
|
||||||
|
/// never matches, which is the correct answer for CI: its drift is information,
|
||||||
|
/// not a verdict.
|
||||||
|
pub fn machine_id() -> String {
|
||||||
|
let host = std::fs::read_to_string("/proc/sys/kernel/hostname")
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_else(|_| "unknown-host".to_string());
|
||||||
|
let threads = std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(0);
|
||||||
|
format!("{host} ({threads} threads)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unix seconds now, or 0 if the clock is before 1970, which it is not.
|
||||||
|
pub fn now_unix() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn metric(direction: Direction, budget: Option<f64>, recorded: Option<f64>) -> Metric {
|
||||||
|
Metric {
|
||||||
|
requirement: "NFR-TEST".into(),
|
||||||
|
what: "a test metric".into(),
|
||||||
|
unit: "ms".into(),
|
||||||
|
direction,
|
||||||
|
machine_sensitive: false,
|
||||||
|
budget,
|
||||||
|
recorded,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cx(same_machine: bool) -> Judging {
|
||||||
|
Judging {
|
||||||
|
reference: true,
|
||||||
|
same_machine,
|
||||||
|
tolerance: 0.15,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_budget_is_directional() {
|
||||||
|
// The bug this exists to prevent: judging a throughput target the way
|
||||||
|
// a latency target is judged, so a suite that got twice as slow passes
|
||||||
|
// and one that got twice as fast fails.
|
||||||
|
let latency = metric(Direction::LowerIsBetter, Some(100.0), None);
|
||||||
|
assert!(latency.judge(101.0, cx(true)).over_budget);
|
||||||
|
assert!(!latency.judge(99.0, cx(true)).over_budget);
|
||||||
|
|
||||||
|
let throughput = metric(Direction::HigherIsBetter, Some(100.0), None);
|
||||||
|
assert!(throughput.judge(99.0, cx(true)).over_budget);
|
||||||
|
assert!(!throughput.judge(101.0, cx(true)).over_budget);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drift_is_positive_when_things_got_worse_whichever_way_that_is() {
|
||||||
|
let latency = metric(Direction::LowerIsBetter, None, Some(100.0));
|
||||||
|
assert!(latency.judge(120.0, cx(true)).drift.unwrap() > 0.0);
|
||||||
|
let throughput = metric(Direction::HigherIsBetter, None, Some(100.0));
|
||||||
|
assert!(throughput.judge(80.0, cx(true)).drift.unwrap() > 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drift_against_another_machine_is_reported_but_never_a_failure() {
|
||||||
|
// CI is not the reference desktop. Its numbers are worth printing and
|
||||||
|
// are not a verdict on anybody's commit.
|
||||||
|
let m = metric(Direction::LowerIsBetter, None, Some(100.0));
|
||||||
|
let elsewhere = m.judge(400.0, cx(false));
|
||||||
|
assert!(elsewhere.drift.unwrap() > 0.15);
|
||||||
|
assert!(!elsewhere.regressed);
|
||||||
|
assert!(m.judge(400.0, cx(true)).regressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unrecorded_metric_cannot_regress() {
|
||||||
|
// The state the committed file ships in. It must gate on the budget
|
||||||
|
// and stay silent about drift, rather than treating null as zero and
|
||||||
|
// declaring an infinite regression.
|
||||||
|
let m = metric(Direction::LowerIsBetter, Some(100.0), None);
|
||||||
|
let j = m.judge(50.0, cx(true));
|
||||||
|
assert!(j.drift.is_none());
|
||||||
|
assert!(!j.regressed);
|
||||||
|
assert!(!j.over_budget);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_machine_sensitive_budget_defers_off_the_reference_desktop() {
|
||||||
|
let mut m = metric(Direction::HigherIsBetter, Some(100.0), None);
|
||||||
|
m.machine_sensitive = true;
|
||||||
|
let on_ci = Judging {
|
||||||
|
reference: false,
|
||||||
|
same_machine: false,
|
||||||
|
tolerance: 0.15,
|
||||||
|
};
|
||||||
|
let j = m.judge(10.0, on_ci);
|
||||||
|
// A two-core runner must not fail a target written for twenty-four.
|
||||||
|
assert!(!j.over_budget);
|
||||||
|
// And the report has to say the budget was not applied, rather than
|
||||||
|
// letting a deferred budget read as a passed one.
|
||||||
|
assert!(j.budget_deferred);
|
||||||
|
// On the reference desktop the same figure is judged.
|
||||||
|
assert!(m.judge(10.0, cx(false)).over_budget);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
//! TRACES: NFR-P1
|
||||||
|
//! Opening a fifty-thousand-image catalog, and what that actually involves.
|
||||||
|
//!
|
||||||
|
//! NFR-P1 says under two seconds on the reference desktop. Until this file
|
||||||
|
//! existed the number had never been measured, which made it a wish — and
|
||||||
|
//! `dr-catalog`'s `lib.rs` has carried an `NFR-P1` tag the whole time on code
|
||||||
|
//! that describes the target rather than checking it. This is the check.
|
||||||
|
//!
|
||||||
|
//! # What counts as "open"
|
||||||
|
//!
|
||||||
|
//! Not `Catalog::open` alone. That call returns before anything is on screen,
|
||||||
|
//! and a user's "the catalog opened" is the moment the grid has cells in it.
|
||||||
|
//! So the measured span is the four things the library view cannot paint
|
||||||
|
//! without:
|
||||||
|
//!
|
||||||
|
//! 1. [`Catalog::open`] — connect, migrate if needed, and **backfill**. The
|
||||||
|
//! backfill is the interesting one: `schema::backfill` runs on every open
|
||||||
|
//! and is three passes over the images table, so it is O(library) work on a
|
||||||
|
//! path whose budget is stated in absolute seconds.
|
||||||
|
//! 2. [`Catalog::count`] — the total, which is what sizes the scrollbar.
|
||||||
|
//! 3. [`Catalog::window`] — the first screenful of rows.
|
||||||
|
//! 4. [`Catalog::timeline`] — the scrubber's buckets, drawn beside the grid
|
||||||
|
//! from the first frame.
|
||||||
|
//!
|
||||||
|
//! `open` alone is reported separately anyway, because if the two ever diverge
|
||||||
|
//! sharply the fix is in a different place.
|
||||||
|
//!
|
||||||
|
//! # What this does *not* measure, said out loud
|
||||||
|
//!
|
||||||
|
//! `ui/dr-ui/src/library.rs` does not call [`Catalog::count`] or
|
||||||
|
//! [`Catalog::window`]. It issues its own SQL — `total_images_scoped`,
|
||||||
|
//! `total_images_filtered` and friends — against the same tables, with a
|
||||||
|
//! `VISIBLE` predicate and a burst-folding clause that this crate cannot see
|
||||||
|
//! without depending on the UI, which would drag Slint into a benchmark job
|
||||||
|
//! that has no display. So the number here is the **catalog crate's** open
|
||||||
|
//! path, and the application's is that plus whatever those queries cost.
|
||||||
|
//!
|
||||||
|
//! That gap is a real limit on what this file can certify, and it has a
|
||||||
|
//! falsifiable end: when the grid's queries move down into `dr-catalog` —
|
||||||
|
//! which is where SQL over catalog tables belongs — this measurement becomes
|
||||||
|
//! the whole of the application's open, and the caveat can be deleted rather
|
||||||
|
//! than argued about.
|
||||||
|
//!
|
||||||
|
//! # Cold and warm
|
||||||
|
//!
|
||||||
|
//! Both are reported. The first open in a process pays for SQLite's page cache
|
||||||
|
//! being empty and for the schema being read; the second pays for neither, and
|
||||||
|
//! is what a user gets when they close and reopen a library in the same
|
||||||
|
//! session. §4.1 asks the question directly for NFR-P8 and it is worth having
|
||||||
|
//! the answer here too. Neither figure is a genuinely cold *disk*: the fixture
|
||||||
|
//! was written by this same suite or by an earlier run of it, so the file is
|
||||||
|
//! in the OS page cache. On the reference desktop's NVMe a truly cold read of
|
||||||
|
//! a ~14 MB file is a few tens of milliseconds; on a spinning disk it is not.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use dr_catalog::{Catalog, Granularity, Query};
|
||||||
|
use dr_types::Selector;
|
||||||
|
|
||||||
|
use crate::stats::{ms, Percentiles, Rng};
|
||||||
|
|
||||||
|
/// Rows fetched for the first screenful.
|
||||||
|
///
|
||||||
|
/// A dense grid on a 4K display is around three hundred cells; four hundred is
|
||||||
|
/// that plus the prefetch margin R2 asks for. Not the whole library, because
|
||||||
|
/// FR-CAT-4 is explicit that memory must not scale with it — a benchmark that
|
||||||
|
/// asked for 50,000 rows would be measuring the requirement's violation.
|
||||||
|
const WINDOW: usize = 400;
|
||||||
|
|
||||||
|
/// The clock the query compiler is handed.
|
||||||
|
///
|
||||||
|
/// Fixed rather than read from the system, so a rolling date filter would
|
||||||
|
/// compile to the same SQL on every run. The unfiltered query does not consult
|
||||||
|
/// it at all; this is here so that adding a dated row later does not silently
|
||||||
|
/// make the suite time-dependent.
|
||||||
|
const NOW: i64 = 2_000_000_000;
|
||||||
|
|
||||||
|
/// What one measured open produced.
|
||||||
|
pub struct Open {
|
||||||
|
/// Open, count, first window, timeline — the whole span, first time.
|
||||||
|
pub cold_ms: f64,
|
||||||
|
/// The same four calls on a second connection in the same process.
|
||||||
|
pub warm_ms: f64,
|
||||||
|
/// [`Catalog::open`] on its own, out of the cold span.
|
||||||
|
pub open_only_ms: f64,
|
||||||
|
/// How many images the count found. Reported so a fixture that failed to
|
||||||
|
/// populate cannot masquerade as a very fast open.
|
||||||
|
pub images: usize,
|
||||||
|
/// Timeline buckets at monthly granularity.
|
||||||
|
pub buckets: usize,
|
||||||
|
/// One window fetched at a random offset — the scroll, minus the drawing.
|
||||||
|
pub window_ms: Percentiles,
|
||||||
|
/// Count plus first window under a rating filter, which compiles to a
|
||||||
|
/// correlated subquery over `versions` (see `query::default_version_scalar`).
|
||||||
|
pub filtered_ms: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Measure an open of the catalog at `path`, then `windows` random windows.
|
||||||
|
pub fn measure(path: &Path, windows: usize) -> Result<Open> {
|
||||||
|
let q = Query::default();
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
let catalog = open(path)?;
|
||||||
|
let open_only_ms = ms(started.elapsed());
|
||||||
|
let images = catalog.count(&q, NOW)?;
|
||||||
|
let rows = catalog.window(&q, 0..WINDOW, NOW)?;
|
||||||
|
let buckets = catalog.timeline(&q, Granularity::Month, NOW)?.len();
|
||||||
|
let cold_ms = ms(started.elapsed());
|
||||||
|
|
||||||
|
// A catalog that returned nothing would post an excellent time. Checked
|
||||||
|
// rather than trusted, because the failure mode is a *fast* wrong answer.
|
||||||
|
anyhow::ensure!(
|
||||||
|
!rows.is_empty() && images > 0 && buckets > 0,
|
||||||
|
"the fixture catalog answered with {images} images, {} rows and {buckets} buckets — \
|
||||||
|
the measurement below would be meaningless",
|
||||||
|
rows.len()
|
||||||
|
);
|
||||||
|
drop(catalog);
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
let catalog = open(path)?;
|
||||||
|
let _ = catalog.count(&q, NOW)?;
|
||||||
|
let _ = catalog.window(&q, 0..WINDOW, NOW)?;
|
||||||
|
let _ = catalog.timeline(&q, Granularity::Month, NOW)?;
|
||||||
|
let warm_ms = ms(started.elapsed());
|
||||||
|
|
||||||
|
// The scroll. Offsets are drawn from a fixed seed rather than swept in
|
||||||
|
// order, because a sequential sweep would be answered increasingly out of
|
||||||
|
// SQLite's own cache and would flatter the deep end of the library — which
|
||||||
|
// is exactly the end a person reaches by dragging the scrollbar.
|
||||||
|
let mut rng = Rng::new(0x5C_20_11);
|
||||||
|
let span = images.saturating_sub(WINDOW).max(1) as u64;
|
||||||
|
// Discarded: the first window of a new connection compiles the statement
|
||||||
|
// and faults in the b-tree's upper levels, and neither recurs while
|
||||||
|
// scrolling.
|
||||||
|
for _ in 0..4 {
|
||||||
|
let start = rng.below(span) as usize;
|
||||||
|
let _ = catalog.window(&q, start..start + WINDOW, NOW)?;
|
||||||
|
}
|
||||||
|
let mut samples = Vec::with_capacity(windows);
|
||||||
|
for _ in 0..windows {
|
||||||
|
let start = rng.below(span) as usize;
|
||||||
|
let t = Instant::now();
|
||||||
|
let rows = catalog.window(&q, start..start + WINDOW, NOW)?;
|
||||||
|
samples.push(ms(t.elapsed()));
|
||||||
|
debug_assert!(!rows.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A filter that has to reach the default version for every candidate row.
|
||||||
|
// Cheap to add and the one query shape in the grid that is not a scan of
|
||||||
|
// `images` alone, so a regression in it would otherwise show up first as a
|
||||||
|
// user complaint.
|
||||||
|
let rated = Query {
|
||||||
|
filter: Selector::Rating { min: 2 },
|
||||||
|
..Query::default()
|
||||||
|
};
|
||||||
|
let t = Instant::now();
|
||||||
|
let _ = catalog.count(&rated, NOW)?;
|
||||||
|
let _ = catalog.window(&rated, 0..WINDOW, NOW)?;
|
||||||
|
let filtered_ms = ms(t.elapsed());
|
||||||
|
|
||||||
|
Ok(Open {
|
||||||
|
cold_ms,
|
||||||
|
warm_ms,
|
||||||
|
open_only_ms,
|
||||||
|
images,
|
||||||
|
buckets,
|
||||||
|
window_ms: Percentiles::of(samples),
|
||||||
|
filtered_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(path: &Path) -> Result<Catalog> {
|
||||||
|
Catalog::open(path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("opening the fixture catalog at {}: {e}", path.display()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
//! The half of a 24 MP export that needs no GPU.
|
||||||
|
//!
|
||||||
|
//! # This cannot certify NFR-P7, and is not tagged as though it could
|
||||||
|
//!
|
||||||
|
//! NFR-P7 is "full-resolution export (24 MP, **full chain**) < 2 s". The full
|
||||||
|
//! chain is decode, demosaic, a GPU render at full resolution, a read-back,
|
||||||
|
//! and then everything `dr-export` does — resize, output sharpening, encode.
|
||||||
|
//! Only the last three of those run without an adapter, and the CI runner has
|
||||||
|
//! none. So what is measured here is the encode half, and no requirement tag
|
||||||
|
//! anywhere in this crate names NFR-P7.
|
||||||
|
//!
|
||||||
|
//! (Written without the tag's own spelling on purpose. `tools/traceability`
|
||||||
|
//! matches the marker anywhere on a line and parses the identifier after it, so
|
||||||
|
//! a sentence saying "there is no tag for NFR-P7" would *be* a tag for NFR-P7 —
|
||||||
|
//! a disclaimer that made itself false.)
|
||||||
|
//!
|
||||||
|
//! That is a deliberate refusal rather than an oversight. `CONTRIBUTING.md`
|
||||||
|
//! asks that a requirement be closed by a test that would fail if the
|
||||||
|
//! behaviour were removed, and `docs/code-health.md` CH-4 records what the
|
||||||
|
//! coverage figure looks like when tags are hung on plumbing instead. A tag
|
||||||
|
//! here would say the export budget is checked; the GPU half of it would still
|
||||||
|
//! be unchecked.
|
||||||
|
//!
|
||||||
|
//! # What the number is still good for
|
||||||
|
//!
|
||||||
|
//! It is a **one-sided** gate, and that is worth having. The encode half is a
|
||||||
|
//! lower bound on the whole: if resizing, sharpening and encoding 24 MP alone
|
||||||
|
//! take longer than two seconds, NFR-P7 is violated no matter how fast the
|
||||||
|
//! render is. So the budget in `docs/bench-baseline.json` is the requirement's
|
||||||
|
//! own 2000 ms, and exceeding it fails the build honestly. Coming in under it
|
||||||
|
//! proves nothing about the requirement, and the report says so rather than
|
||||||
|
//! printing a tick.
|
||||||
|
//!
|
||||||
|
//! # Two rows
|
||||||
|
//!
|
||||||
|
//! `Original` is the one the budget is judged on: it is the archival export,
|
||||||
|
//! the largest encode, and the case FR-EXP-9 is about. `LongEdge(2048)` is the
|
||||||
|
//! ordinary web export, where the resample does real work and the encode does
|
||||||
|
//! very little — it is reported because a regression in `size::resample` would
|
||||||
|
//! be invisible in the first row, where source and target dimensions are equal.
|
||||||
|
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use dr_export::{export, Frame};
|
||||||
|
use dr_types::{ExportFormat, ExportSettings, OutputSharpening, SizingMode};
|
||||||
|
|
||||||
|
use crate::fixture::plausible_frame;
|
||||||
|
use crate::stats::{ms, Percentiles};
|
||||||
|
|
||||||
|
/// The frame every row exports.
|
||||||
|
///
|
||||||
|
/// 6000 × 4000 is 24.0 MP — a full-frame body, and the exact figure NFR-P7
|
||||||
|
/// names. As RGBA8 it is 96 MB, and the export path holds a resized copy and a
|
||||||
|
/// sharpened copy alongside it, so a run needs roughly 300 MB of headroom.
|
||||||
|
/// Worth knowing before a small runner reports this as a mysterious kill.
|
||||||
|
pub const SOURCE: (u32, u32) = (6000, 4000);
|
||||||
|
|
||||||
|
/// Measured exports per row. Not a hundred: one 24 MP encode is most of a
|
||||||
|
/// second, and a hundred of them would be a two-minute CI step to establish
|
||||||
|
/// what five establish. Nearest-rank p99 of five is the worst of the five,
|
||||||
|
/// which for a row this expensive is the honest reading anyway.
|
||||||
|
const RUNS: usize = 5;
|
||||||
|
|
||||||
|
/// A row of the export table.
|
||||||
|
pub struct EncodeRun {
|
||||||
|
/// The name this row carries in `docs/bench-baseline.json`.
|
||||||
|
pub key: &'static str,
|
||||||
|
pub label: &'static str,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
/// Encoded file size, so a row that silently stopped compressing is
|
||||||
|
/// visible as well as a row that got slow.
|
||||||
|
pub bytes: usize,
|
||||||
|
pub times: Percentiles,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export the same 24 MP frame at each sizing, timing `dr_export::export`.
|
||||||
|
pub fn measure() -> Result<Vec<EncodeRun>> {
|
||||||
|
let (w, h) = SOURCE;
|
||||||
|
// Detail at every scale, for the same reason the thumbnail fixture has it:
|
||||||
|
// a flat frame compresses to almost nothing and would make the encoder
|
||||||
|
// look several times faster than any photograph makes it.
|
||||||
|
let frame = Frame::new(w, h, plausible_frame(w, h, 0))
|
||||||
|
.map_err(|e| anyhow::anyhow!("building the 24 MP bench frame: {e}"))?;
|
||||||
|
|
||||||
|
let sizings: [(&'static str, &'static str, SizingMode); 2] = [
|
||||||
|
("export_24mp_original_ms", "original", SizingMode::Original),
|
||||||
|
(
|
||||||
|
"export_24mp_long_edge_2048_ms",
|
||||||
|
"long edge 2048",
|
||||||
|
SizingMode::LongEdge(2048),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut rows = Vec::with_capacity(sizings.len());
|
||||||
|
for (key, label, sizing) in sizings {
|
||||||
|
let settings = ExportSettings {
|
||||||
|
format: ExportFormat::Jpeg,
|
||||||
|
// 90 is the default and what a photographer would not need to
|
||||||
|
// change; quality moves encode time, so it belongs in the record.
|
||||||
|
quality: 90,
|
||||||
|
sizing,
|
||||||
|
sharpening: OutputSharpening::Screen,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Discarded. The first export of a process grows the allocator to hold
|
||||||
|
// three 24 MP buffers, which is a cost paid once and not per file in
|
||||||
|
// the batch export FR-EXP-7 describes.
|
||||||
|
let warm = run_once(&frame, &settings)?;
|
||||||
|
|
||||||
|
let mut samples = Vec::with_capacity(RUNS);
|
||||||
|
let mut last = warm;
|
||||||
|
for _ in 0..RUNS {
|
||||||
|
let started = Instant::now();
|
||||||
|
last = run_once(&frame, &settings)?;
|
||||||
|
samples.push(ms(started.elapsed()));
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push(EncodeRun {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
width: last.0,
|
||||||
|
height: last.1,
|
||||||
|
bytes: last.2,
|
||||||
|
times: Percentiles::of(samples),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One export, returning what it produced rather than the pixels.
|
||||||
|
fn run_once(frame: &Frame, settings: &ExportSettings) -> Result<(u32, u32, usize)> {
|
||||||
|
let encoded = export(frame, settings, "bench.jpg".to_string(), None)
|
||||||
|
.map_err(|e| anyhow::anyhow!("exporting the bench frame: {e}"))?;
|
||||||
|
Ok((encoded.width, encoded.height, encoded.bytes.len()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
//! The synthetic 50k catalog, and the handful of real files it points at.
|
||||||
|
//!
|
||||||
|
//! `docs/requirements.md` §8 asks for "an automated benchmark suite against a
|
||||||
|
//! synthetic 50k catalog". The hard part of that sentence is *50k*: a real
|
||||||
|
//! library of that size is several terabytes and cannot live in a repository,
|
||||||
|
//! in a CI cache, or on a laptop that also has to compile the thing.
|
||||||
|
//!
|
||||||
|
//! # The trick, and what it costs
|
||||||
|
//!
|
||||||
|
//! Rows are cheap and pixels are not. So this builds **fifty thousand catalog
|
||||||
|
//! rows** over a **pool of a dozen real image files**, each referenced by
|
||||||
|
//! several thousand of them. Everything the catalog half of the suite measures
|
||||||
|
//! — opening, counting, windowing, bucketing a timeline — touches only rows,
|
||||||
|
//! and is therefore exact. Everything the pixel half measures — decode,
|
||||||
|
//! downscale, orient, encode — touches one file at a time and does not care
|
||||||
|
//! how many rows point at it. The fixture is ~14 MB on disk instead of ~2 TB
|
||||||
|
//! and neither half is flattered by that.
|
||||||
|
//!
|
||||||
|
//! What it *does* cost is stated rather than hidden: the file pool is small
|
||||||
|
//! enough to sit in the OS page cache, so [`crate::thumbnails`] measures CPU
|
||||||
|
//! throughput with the read already paid for. That is the right thing to
|
||||||
|
//! measure for NFR-P3 — the target is written about the embedded preview path,
|
||||||
|
//! not about a disk — but it is not a claim about a cold library on spinning
|
||||||
|
//! rust, and the harness does not make one.
|
||||||
|
//!
|
||||||
|
//! # Reproducible from a seed
|
||||||
|
//!
|
||||||
|
//! Every value comes from [`Rng`], seeded once. Two machines running the same
|
||||||
|
//! seed build byte-comparable catalogs, which is the property that lets a
|
||||||
|
//! number measured on the reference desktop be compared with a number measured
|
||||||
|
//! anywhere else. [`Stamp`] records what a directory was built from, so a
|
||||||
|
//! fixture is reused when it matches and rebuilt when it does not — including
|
||||||
|
//! when `dr-catalog`'s schema version moves, since a catalog built by an older
|
||||||
|
//! build would otherwise be measured through a migration that a user's would
|
||||||
|
//! not run.
|
||||||
|
//!
|
||||||
|
//! # The sources are generated, not committed
|
||||||
|
//!
|
||||||
|
//! No photograph in this repository is licensed for redistribution, and a
|
||||||
|
//! dozen camera previews would be megabytes of binary in git for ever. So the
|
||||||
|
//! pool is synthesised: a coarse gradient with a fine dither on top, which is
|
||||||
|
//! the same shape `core/dr-gpu/examples/frame_budget.rs` synthesises its source
|
||||||
|
//! from and for the same reason. A flat frame lets the memory system serve
|
||||||
|
//! every sample from one cache line, which flatters a box filter by an amount
|
||||||
|
//! that has nothing to do with photographs; pure noise defeats the JPEG
|
||||||
|
//! encoder's entropy coder in the other direction and would make the encode
|
||||||
|
//! half of a thumbnail look worse than any real image ever does.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use dr_catalog::Catalog;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::stats::Rng;
|
||||||
|
|
||||||
|
/// How many distinct image files the pool holds.
|
||||||
|
///
|
||||||
|
/// Twelve rather than one, so that a decode measured over a batch is not one
|
||||||
|
/// file's quirks repeated — a single frame that happened to compress unusually
|
||||||
|
/// well would set the whole number — and rather than fifty thousand, so the
|
||||||
|
/// fixture stays a directory a person can look at.
|
||||||
|
pub const SOURCE_POOL: usize = 12;
|
||||||
|
|
||||||
|
/// The size of one pooled file, in pixels.
|
||||||
|
///
|
||||||
|
/// 1620×1080 is not a round number: it is what `core/dr-decode/src/preview.rs`
|
||||||
|
/// records a Canon CR2 carrying in IFD2, and the embedded preview is what
|
||||||
|
/// NFR-P3 names. A camera JPEG is 24 MP and a camera *preview* is about this,
|
||||||
|
/// so measuring the preview path against a 24 MP file would measure something
|
||||||
|
/// the sweep never does.
|
||||||
|
pub const PREVIEW: (u32, u32) = (1620, 1080);
|
||||||
|
|
||||||
|
/// Folders the rows are spread across.
|
||||||
|
///
|
||||||
|
/// Spread evenly and without regard to capture date, because what a folder
|
||||||
|
/// count decides is the cost of the folder filter's `IN (SELECT …)` and the
|
||||||
|
/// size of the `folders` table — not which image is in which.
|
||||||
|
const FOLDERS: usize = 400;
|
||||||
|
|
||||||
|
/// The earliest capture time in the fixture: 13 December 2015, UTC.
|
||||||
|
///
|
||||||
|
/// Fixed rather than relative to the clock. A library whose dates moved with
|
||||||
|
/// the calendar would make `timeline` bucket differently from one month to the
|
||||||
|
/// next, and a benchmark that measures a different query each time it runs is
|
||||||
|
/// not measuring a regression.
|
||||||
|
const EPOCH: i64 = 1_450_000_000;
|
||||||
|
|
||||||
|
/// The span capture times are drawn from: twelve years.
|
||||||
|
///
|
||||||
|
/// Long enough that the timeline query has real structure to bucket — at
|
||||||
|
/// monthly granularity that is ~144 buckets, which is the shape the scrubber
|
||||||
|
/// actually draws — and not so long that a year holds too few frames to look
|
||||||
|
/// like a library.
|
||||||
|
const SPAN: i64 = 12 * 365 * 86_400;
|
||||||
|
|
||||||
|
/// Bodies and lenses, for the columns the camera and lens filters read.
|
||||||
|
const CAMERAS: [&str; 6] = [
|
||||||
|
"Canon EOS R5",
|
||||||
|
"Nikon Z 7II",
|
||||||
|
"Sony ILCE-7RM5",
|
||||||
|
"Fujifilm X-T5",
|
||||||
|
"Panasonic DC-S5M2",
|
||||||
|
"OM SYSTEM OM-1",
|
||||||
|
];
|
||||||
|
|
||||||
|
const LENSES: [&str; 6] = [
|
||||||
|
"RF24-70mm F2.8 L IS USM",
|
||||||
|
"NIKKOR Z 50mm f/1.8 S",
|
||||||
|
"FE 85mm F1.4 GM",
|
||||||
|
"XF16-55mmF2.8 R LM WR",
|
||||||
|
"LUMIX S 20-60mm F3.5-5.6",
|
||||||
|
"M.Zuiko Digital ED 12-40mm F2.8",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// What a fixture directory was built from.
|
||||||
|
///
|
||||||
|
/// Written beside the catalog and compared on every run. A mismatch rebuilds:
|
||||||
|
/// silently reusing a fixture built from a different seed, a different row
|
||||||
|
/// count or an older schema would compare two numbers that describe two
|
||||||
|
/// different workloads, which is worse than having no number at all.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Stamp {
|
||||||
|
/// Bumped by hand whenever anything in this file changes what gets built.
|
||||||
|
/// The seed cannot carry that: the same seed through different generation
|
||||||
|
/// code produces a different library.
|
||||||
|
pub generator: u32,
|
||||||
|
pub seed: u64,
|
||||||
|
pub images: usize,
|
||||||
|
pub sources: usize,
|
||||||
|
pub folders: usize,
|
||||||
|
pub preview_width: u32,
|
||||||
|
pub preview_height: u32,
|
||||||
|
/// `dr-catalog`'s schema version at build time.
|
||||||
|
pub schema_version: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bump on any change to what [`build`] writes.
|
||||||
|
const GENERATOR: u32 = 1;
|
||||||
|
|
||||||
|
/// A built fixture on disk.
|
||||||
|
pub struct Fixture {
|
||||||
|
pub dir: PathBuf,
|
||||||
|
pub catalog: PathBuf,
|
||||||
|
pub thumbs: PathBuf,
|
||||||
|
pub sources: Vec<PathBuf>,
|
||||||
|
pub stamp: Stamp,
|
||||||
|
/// The catalog file's size, reported because it is the thing an open has
|
||||||
|
/// to read and because it is the honest denominator for "is 2 s a lot".
|
||||||
|
pub catalog_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a fixture lives by default.
|
||||||
|
///
|
||||||
|
/// The temporary directory rather than `target/`, for two reasons. It survives
|
||||||
|
/// `cargo clean`, so a fixture is built once per machine rather than once per
|
||||||
|
/// clean; and it is not inside anything CI caches, so a 14 MB catalog is not
|
||||||
|
/// uploaded and downloaded on every push to save the two seconds it takes to
|
||||||
|
/// generate. `DR_BENCH_DIR` overrides it.
|
||||||
|
pub fn default_dir() -> PathBuf {
|
||||||
|
match std::env::var_os("DR_BENCH_DIR") {
|
||||||
|
Some(dir) => PathBuf::from(dir),
|
||||||
|
None => std::env::temp_dir().join("darkroom-bench"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the fixture under `dir`, or confirm the one already there.
|
||||||
|
///
|
||||||
|
/// Returns whether it had to be built, so the caller can say so: a run that
|
||||||
|
/// includes fixture generation has a warm page cache for the catalog file it
|
||||||
|
/// is about to open, and a reader comparing two numbers deserves to know which
|
||||||
|
/// of them was measured that way.
|
||||||
|
pub fn build(dir: &Path, seed: u64, images: usize) -> Result<(Fixture, bool)> {
|
||||||
|
let stamp = Stamp {
|
||||||
|
generator: GENERATOR,
|
||||||
|
seed,
|
||||||
|
images,
|
||||||
|
sources: SOURCE_POOL,
|
||||||
|
folders: FOLDERS,
|
||||||
|
preview_width: PREVIEW.0,
|
||||||
|
preview_height: PREVIEW.1,
|
||||||
|
schema_version: dr_catalog::schema::SCHEMA_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
let catalog = dir.join("catalog.sqlite");
|
||||||
|
let stamp_path = dir.join("stamp.json");
|
||||||
|
let sources: Vec<PathBuf> = (0..SOURCE_POOL)
|
||||||
|
.map(|i| dir.join("sources").join(format!("preview-{i:02}.jpg")))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let usable = matches_stamp(&stamp_path, &stamp)
|
||||||
|
&& catalog.is_file()
|
||||||
|
&& sources.iter().all(|p| p.is_file());
|
||||||
|
|
||||||
|
if !usable {
|
||||||
|
std::fs::create_dir_all(dir.join("sources"))
|
||||||
|
.with_context(|| format!("creating the fixture directory {}", dir.display()))?;
|
||||||
|
// The stamp goes last. A build interrupted halfway leaves no stamp, so
|
||||||
|
// the next run rebuilds rather than measuring a truncated catalog.
|
||||||
|
let _ = std::fs::remove_file(&stamp_path);
|
||||||
|
write_sources(&sources, seed)?;
|
||||||
|
write_catalog(&catalog, seed, images)?;
|
||||||
|
std::fs::write(&stamp_path, serde_json::to_vec_pretty(&stamp)?)
|
||||||
|
.with_context(|| format!("writing {}", stamp_path.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let catalog_bytes = std::fs::metadata(&catalog)
|
||||||
|
.with_context(|| format!("stat {}", catalog.display()))?
|
||||||
|
.len();
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
Fixture {
|
||||||
|
dir: dir.to_path_buf(),
|
||||||
|
catalog,
|
||||||
|
thumbs: dir.join("thumbs"),
|
||||||
|
sources,
|
||||||
|
stamp,
|
||||||
|
catalog_bytes,
|
||||||
|
},
|
||||||
|
!usable,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches_stamp(path: &Path, want: &Stamp) -> bool {
|
||||||
|
let Ok(text) = std::fs::read_to_string(path) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
matches!(serde_json::from_str::<Stamp>(&text), Ok(have) if have == *want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The file pool
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Write the pool of JPEGs the pixel half decodes.
|
||||||
|
///
|
||||||
|
/// Encoded through [`dr_thumbs::encode_rgba`] rather than a second encoder
|
||||||
|
/// call of this crate's own. That is the quality the store already uses (82),
|
||||||
|
/// which is a little below what a camera writes its previews at, and it is one
|
||||||
|
/// fewer place for an encoder setting to drift. Stated because it is visible
|
||||||
|
/// in the result: a slightly softer source decodes marginally faster than a
|
||||||
|
/// camera's own preview would.
|
||||||
|
fn write_sources(paths: &[PathBuf], seed: u64) -> Result<()> {
|
||||||
|
let (w, h) = PREVIEW;
|
||||||
|
for (i, path) in paths.iter().enumerate() {
|
||||||
|
let rgba = plausible_frame(w, h, seed ^ (i as u64));
|
||||||
|
let jpeg = dr_thumbs::encode_rgba(w, h, &rgba)
|
||||||
|
.map_err(|e| anyhow::anyhow!("encoding the fixture source {}: {e}", path.display()))?;
|
||||||
|
std::fs::write(path, jpeg).with_context(|| format!("writing {}", path.display()))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RGBA with detail at every scale: a coarse gradient plus a fine dither.
|
||||||
|
///
|
||||||
|
/// See this module's header for why neither a flat frame nor pure noise would
|
||||||
|
/// do. `salt` moves the gradient and the dither together so the twelve files
|
||||||
|
/// differ from one another rather than being twelve copies with different
|
||||||
|
/// names — a JPEG encoder that saw the same image twelve times would have the
|
||||||
|
/// same cache behaviour every time, which a library does not.
|
||||||
|
pub fn plausible_frame(w: u32, h: u32, salt: u64) -> Vec<u8> {
|
||||||
|
let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4];
|
||||||
|
let bias = (salt % 97) as u32;
|
||||||
|
for y in 0..h as usize {
|
||||||
|
let row = y * (w as usize) * 4;
|
||||||
|
for x in 0..w as usize {
|
||||||
|
// A cheap integer hash, so neighbouring pixels differ and the
|
||||||
|
// encoder has real high-frequency content to spend bits on.
|
||||||
|
let n = (x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(1_640_531_527)) >> 13;
|
||||||
|
let dither = (n & 0x1f) as u32;
|
||||||
|
let gx = (x * 200 / (w as usize).max(1)) as u32;
|
||||||
|
let gy = (y * 55 / (h as usize).max(1)) as u32;
|
||||||
|
let px = &mut rgba[row + x * 4..row + x * 4 + 4];
|
||||||
|
px[0] = (30 + bias + gx + dither).min(255) as u8;
|
||||||
|
px[1] = (40 + gy + dither).min(255) as u8;
|
||||||
|
px[2] = (60 + gx / 2 + gy + dither).min(255) as u8;
|
||||||
|
px[3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rgba
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The catalog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Write a catalog holding `images` rows, plus a default version for each.
|
||||||
|
///
|
||||||
|
/// The default versions are not decoration. `Catalog::open` backfills them for
|
||||||
|
/// any image that lacks one (see `schema::backfill`), so a fixture without
|
||||||
|
/// them would charge every measured open for fifty thousand inserts once and
|
||||||
|
/// nothing thereafter — a first number that bore no relation to the second,
|
||||||
|
/// and a benchmark whose result depended on whether it had been run before.
|
||||||
|
fn write_catalog(path: &Path, seed: u64, images: usize) -> Result<()> {
|
||||||
|
for suffix in ["", "-wal", "-shm"] {
|
||||||
|
let mut p = path.as_os_str().to_os_string();
|
||||||
|
p.push(suffix);
|
||||||
|
let _ = std::fs::remove_file(PathBuf::from(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
let catalog = Catalog::open(path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("creating the fixture catalog at {}: {e}", path.display()))?;
|
||||||
|
let conn = catalog.connection();
|
||||||
|
let mut rng = Rng::new(seed);
|
||||||
|
|
||||||
|
conn.execute_batch("BEGIN")?;
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO roots(id, kind, label, last_seen, scan_generation)
|
||||||
|
VALUES (1, 'local', '/library', ?1, 1)",
|
||||||
|
rusqlite::params![EPOCH],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut folder = conn.prepare(
|
||||||
|
"INSERT INTO folders(id, root_id, parent_id, path, mtime, entry_count,
|
||||||
|
scanned_generation)
|
||||||
|
VALUES (?1, 1, NULL, ?2, ?3, ?4, 1)",
|
||||||
|
)?;
|
||||||
|
for f in 0..FOLDERS {
|
||||||
|
let id = f as i64 + 1;
|
||||||
|
let folder_path = format!("/library/{:04}/{:02}", 2016 + f / 12, f % 12 + 1);
|
||||||
|
let mtime = EPOCH + f as i64 * 86_400;
|
||||||
|
let entries = (images / FOLDERS.max(1)) as i64;
|
||||||
|
folder.execute(rusqlite::params![id, folder_path, mtime, entries])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut image = conn.prepare(
|
||||||
|
"INSERT INTO images(id, root_id, folder_id, source_ref, format, w, h,
|
||||||
|
captured_at, captured_offset, camera, lens, iso,
|
||||||
|
aperture, shutter, availability, file_size,
|
||||||
|
file_mtime, metadata_state, added_at)
|
||||||
|
VALUES (?1, 1, ?2, ?3, 'CR3', ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12,
|
||||||
|
?13, ?14, ?15, ?16, ?17)",
|
||||||
|
)?;
|
||||||
|
let mut version = conn.prepare(
|
||||||
|
"INSERT INTO versions(id, image_id, uuid, name, is_default, rating,
|
||||||
|
label, flag)
|
||||||
|
VALUES (?1, ?1, ?2, 'Original', 1, ?3, ?4, ?5)",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Every value is bound to a local before it reaches `params!`. Not
|
||||||
|
// style: the macro takes a reference to each argument, and an
|
||||||
|
// expression like `TABLE[rng.below(n) as usize]` inside it borrows an
|
||||||
|
// element of a temporary array while `rng` is also being borrowed
|
||||||
|
// mutably. Locals make the evaluation order and the lifetimes obvious.
|
||||||
|
const OFFSETS: [i64; 5] = [0, 60, 120, -300, 540];
|
||||||
|
const ISOS: [i64; 7] = [100, 200, 400, 800, 1600, 3200, 6400];
|
||||||
|
const APERTURES: [f64; 6] = [1.4, 1.8, 2.8, 4.0, 5.6, 8.0];
|
||||||
|
const SHUTTERS: [f64; 6] = [0.004, 0.008, 0.0167, 0.005, 0.002, 0.5];
|
||||||
|
// Mostly metadata-only, as a large library on a laptop is: some
|
||||||
|
// previewed, a few with the original present.
|
||||||
|
const AVAILABILITY: [i64; 6] = [0, 0, 0, 1, 1, 2];
|
||||||
|
|
||||||
|
for i in 0..images {
|
||||||
|
let id = i as i64 + 1;
|
||||||
|
let bucket = i % FOLDERS;
|
||||||
|
let folder = bucket as i64 + 1;
|
||||||
|
let source_ref = format!(
|
||||||
|
"/library/{:04}/{:02}/IMG_{id:05}.CR3",
|
||||||
|
2016 + bucket / 12,
|
||||||
|
bucket % 12 + 1
|
||||||
|
);
|
||||||
|
let captured = EPOCH + rng.below(SPAN as u64) as i64;
|
||||||
|
// A quarter of the library shot in portrait, which is what makes
|
||||||
|
// the thumbnail path's orientation permutation a real cost rather
|
||||||
|
// than a branch that is never taken.
|
||||||
|
let (w, h) = if i % 4 == 3 {
|
||||||
|
(4000i64, 6000i64)
|
||||||
|
} else {
|
||||||
|
(6000i64, 4000i64)
|
||||||
|
};
|
||||||
|
// Two per cent still awaiting full EXIF — a library is never
|
||||||
|
// entirely finished being read, and the grid has to render that
|
||||||
|
// state (`metadata_state` 1).
|
||||||
|
let state: i64 = if rng.below(50) == 0 { 1 } else { 2 };
|
||||||
|
let camera = CAMERAS[rng.below(CAMERAS.len() as u64) as usize];
|
||||||
|
let lens = LENSES[rng.below(LENSES.len() as u64) as usize];
|
||||||
|
// Minutes east of UTC: a library shot in a handful of places.
|
||||||
|
let offset = OFFSETS[rng.below(OFFSETS.len() as u64) as usize];
|
||||||
|
let iso = ISOS[rng.below(ISOS.len() as u64) as usize];
|
||||||
|
let aperture = APERTURES[rng.below(APERTURES.len() as u64) as usize];
|
||||||
|
let shutter = SHUTTERS[rng.below(SHUTTERS.len() as u64) as usize];
|
||||||
|
let availability = AVAILABILITY[rng.below(AVAILABILITY.len() as u64) as usize];
|
||||||
|
let file_size = 20_000_000i64 + rng.below(30_000_000) as i64;
|
||||||
|
let file_mtime = captured + 60;
|
||||||
|
let added_at = captured + 3600;
|
||||||
|
|
||||||
|
image.execute(rusqlite::params![
|
||||||
|
id,
|
||||||
|
folder,
|
||||||
|
source_ref,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
captured,
|
||||||
|
offset,
|
||||||
|
camera,
|
||||||
|
lens,
|
||||||
|
iso,
|
||||||
|
aperture,
|
||||||
|
shutter,
|
||||||
|
availability,
|
||||||
|
file_size,
|
||||||
|
file_mtime,
|
||||||
|
state,
|
||||||
|
added_at,
|
||||||
|
])?;
|
||||||
|
|
||||||
|
// Ratings skewed the way a culled library is: most unrated, a few
|
||||||
|
// picks, fewer still at five stars.
|
||||||
|
let rating: i64 = match rng.below(100) {
|
||||||
|
0..=69 => 0,
|
||||||
|
70..=84 => 1,
|
||||||
|
85..=93 => 2,
|
||||||
|
94..=97 => 3,
|
||||||
|
98 => 4,
|
||||||
|
_ => 5,
|
||||||
|
};
|
||||||
|
let flag: i64 = match rng.below(100) {
|
||||||
|
0..=79 => 0,
|
||||||
|
80..=94 => 1,
|
||||||
|
_ => 2,
|
||||||
|
};
|
||||||
|
let label: Option<i64> = match rng.below(100) {
|
||||||
|
0..=89 => None,
|
||||||
|
n => Some((n % 5) as i64 + 1),
|
||||||
|
};
|
||||||
|
let a = rng.next_u64();
|
||||||
|
let b = rng.next_u64();
|
||||||
|
let uuid = format!("{a:016x}{b:016x}");
|
||||||
|
version.execute(rusqlite::params![id, uuid, rating, label, flag])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.execute_batch("COMMIT")?;
|
||||||
|
|
||||||
|
// Deliberately no `ANALYZE`. The application never runs one, so a fixture
|
||||||
|
// that did would be measuring a query plan no user's catalog gets — and a
|
||||||
|
// plan chosen from statistics is exactly the sort of thing that would make
|
||||||
|
// the benchmark faster than the product.
|
||||||
|
|
||||||
|
// Dropping the connection checkpoints the WAL, so the file the next open
|
||||||
|
// reads is the whole catalog rather than a stub plus a journal.
|
||||||
|
drop(catalog);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,540 @@
|
|||||||
|
//! The benchmark suite `docs/requirements.md` §8 has been promising.
|
||||||
|
//!
|
||||||
|
//! §8 says performance is verified by *"an automated benchmark suite against a
|
||||||
|
//! synthetic 50k catalog, run per-commit … A regression beyond stated
|
||||||
|
//! tolerance fails the build."* Until this crate there was none: no `benches/`,
|
||||||
|
//! no `[[bench]]`, no criterion, no fixture. Ten performance requirements could
|
||||||
|
//! therefore be neither passed nor failed, and five of them carried a
|
||||||
|
//! requirement tag anyway.
|
||||||
|
//!
|
||||||
|
//! ```sh
|
||||||
|
//! cargo run --release -p dr-bench -- check # measure and gate
|
||||||
|
//! cargo run --release -p dr-bench -- check --reference # …on the reference desktop
|
||||||
|
//! cargo run --release -p dr-bench -- record --reference # rewrite the baseline
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! **Release, always.** The workspace builds its own crates at `opt-level = 0`
|
||||||
|
//! in dev (see the root `Cargo.toml`), and every number here is dominated by
|
||||||
|
//! this workspace's own code — the JPEG decode, the box filter, the resample,
|
||||||
|
//! the sharpen. A debug run measures rustc's shadow, exactly as
|
||||||
|
//! `core/dr-gpu/examples/frame_budget.rs` warns for the same reason. The
|
||||||
|
//! harness says so at the top of every report rather than trusting anyone to
|
||||||
|
//! remember.
|
||||||
|
//!
|
||||||
|
//! # What this covers, and what it deliberately does not
|
||||||
|
//!
|
||||||
|
//! Covered, with a gate:
|
||||||
|
//!
|
||||||
|
//! - **NFR-P1** and R2's catalog clause — opening a 50k catalog and painting
|
||||||
|
//! the first grid ([`catalog_open`]).
|
||||||
|
//! - **NFR-P3** — thumbnail throughput on the embedded preview path
|
||||||
|
//! ([`thumbnails`]).
|
||||||
|
//!
|
||||||
|
//! Measured, reported, and honestly *not* tagged, because only part of the
|
||||||
|
//! requirement is in reach without a GPU or a running UI:
|
||||||
|
//!
|
||||||
|
//! - **NFR-P7** — the encode half of a 24 MP export ([`exporting`]). A
|
||||||
|
//! one-sided gate: it can fail the requirement, it cannot pass it.
|
||||||
|
//! - **NFR-P8** — the catalog layer's share of idle RSS ([`memory`]), together
|
||||||
|
//! with the answer to the question §4.1 asks about GPU memory.
|
||||||
|
//!
|
||||||
|
//! Out of scope entirely, and left to say so rather than faked: NFR-P2, P4,
|
||||||
|
//! P5, P6, P9, P10, P11, P12, P13, P14 and P15. Every one of them needs a
|
||||||
|
//! frame-timing probe inside a running Slint application, a GPU adapter, or
|
||||||
|
//! both. The GPU half of the suite that *does* exist is
|
||||||
|
//! `core/dr-gpu/tests/frame_budget.rs`, which asserts FR-DSP-3 and skips itself
|
||||||
|
//! where there is no adapter; `.gitea/workflows/benchmark.yml` runs it as its
|
||||||
|
//! own job for exactly that reason.
|
||||||
|
//!
|
||||||
|
//! # Exit codes
|
||||||
|
//!
|
||||||
|
//! `0` everything inside its budget and its tolerance; `1` a gate failed; `2`
|
||||||
|
//! the harness itself could not run. Distinguished because a CI log that says
|
||||||
|
//! "failed" should not leave anyone guessing whether the code got slower or the
|
||||||
|
//! fixture would not build.
|
||||||
|
|
||||||
|
mod baseline;
|
||||||
|
mod catalog_open;
|
||||||
|
mod exporting;
|
||||||
|
mod fixture;
|
||||||
|
mod memory;
|
||||||
|
mod stats;
|
||||||
|
mod thumbnails;
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use baseline::{Baseline, Judging};
|
||||||
|
|
||||||
|
/// The seed the committed baseline describes.
|
||||||
|
///
|
||||||
|
/// Changing it invalidates every recorded figure, because it changes the
|
||||||
|
/// library being measured. That is why it is a constant here and a field in
|
||||||
|
/// the stamp rather than something a flag quietly varies.
|
||||||
|
const SEED: u64 = 20_260_829;
|
||||||
|
|
||||||
|
/// Rows in the synthetic library. §8 says 50k; this is that.
|
||||||
|
const IMAGES: usize = 50_000;
|
||||||
|
|
||||||
|
/// Thumbnails produced for the throughput row.
|
||||||
|
///
|
||||||
|
/// Twelve hundred rather than fifty thousand. At the target rate the whole
|
||||||
|
/// library is eight minutes of CI, and a rate measured over 1,200 images is
|
||||||
|
/// the same rate — the sweep has no state that changes after the first chunk,
|
||||||
|
/// which the per-image percentile alongside it is there to demonstrate.
|
||||||
|
const THUMBNAILS: usize = 1_200;
|
||||||
|
|
||||||
|
/// Windows fetched for the scroll row.
|
||||||
|
///
|
||||||
|
/// A hundred, so nearest-rank puts the 99th percentile on the second-worst —
|
||||||
|
/// the same reading `core/dr-gpu/examples/frame_budget.rs` takes of a hundred
|
||||||
|
/// frames, and the reason it takes it: one bad one in a hundred is one too
|
||||||
|
/// many, and a single scheduler hiccup on an unrelated process should not
|
||||||
|
/// decide the verdict on its own.
|
||||||
|
const WINDOWS: usize = 100;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
env_logger::init();
|
||||||
|
match run() {
|
||||||
|
Ok(true) => {}
|
||||||
|
Ok(false) => std::process::exit(1),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("dr-bench: {e:#}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether every gate passed.
|
||||||
|
fn run() -> Result<bool> {
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
let command = args.next().unwrap_or_else(|| "help".to_string());
|
||||||
|
|
||||||
|
// The probe takes a path and nothing else — see `memory` for why it is a
|
||||||
|
// separate process rather than a function call.
|
||||||
|
if command == "memory-probe" {
|
||||||
|
let path = args
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("memory-probe needs a catalog path"))?;
|
||||||
|
memory::probe(&PathBuf::from(path))?;
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut reference = false;
|
||||||
|
let mut fixture_dir = fixture::default_dir();
|
||||||
|
let mut baseline_path = Baseline::default_path()?;
|
||||||
|
let mut lanes = thumbnails::default_lanes();
|
||||||
|
let mut thumbnail_count = THUMBNAILS;
|
||||||
|
|
||||||
|
while let Some(flag) = args.next() {
|
||||||
|
match flag.as_str() {
|
||||||
|
"--reference" => reference = true,
|
||||||
|
"--fixture" => fixture_dir = PathBuf::from(expect_value(&mut args, "--fixture")?),
|
||||||
|
"--baseline" => baseline_path = PathBuf::from(expect_value(&mut args, "--baseline")?),
|
||||||
|
"--lanes" => lanes = expect_value(&mut args, "--lanes")?.parse()?,
|
||||||
|
"--thumbnails" => thumbnail_count = expect_value(&mut args, "--thumbnails")?.parse()?,
|
||||||
|
other => anyhow::bail!("unknown flag {other}; try `dr-bench help`"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match command.as_str() {
|
||||||
|
"run" => measure_and_report(
|
||||||
|
&fixture_dir,
|
||||||
|
&baseline_path,
|
||||||
|
reference,
|
||||||
|
lanes,
|
||||||
|
thumbnail_count,
|
||||||
|
Mode::Report,
|
||||||
|
),
|
||||||
|
"check" => measure_and_report(
|
||||||
|
&fixture_dir,
|
||||||
|
&baseline_path,
|
||||||
|
reference,
|
||||||
|
lanes,
|
||||||
|
thumbnail_count,
|
||||||
|
Mode::Gate,
|
||||||
|
),
|
||||||
|
"record" => measure_and_report(
|
||||||
|
&fixture_dir,
|
||||||
|
&baseline_path,
|
||||||
|
reference,
|
||||||
|
lanes,
|
||||||
|
thumbnail_count,
|
||||||
|
Mode::Record,
|
||||||
|
),
|
||||||
|
"help" | "--help" | "-h" => {
|
||||||
|
print_help();
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
other => anyhow::bail!("unknown command {other}; try `dr-bench help`"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expect_value(args: &mut impl Iterator<Item = String>, flag: &str) -> Result<String> {
|
||||||
|
args.next()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("{flag} needs a value"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a run does with what it measured.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Mode {
|
||||||
|
/// Print, judge nothing.
|
||||||
|
Report,
|
||||||
|
/// Print and fail the build on a violated budget or a regression.
|
||||||
|
Gate,
|
||||||
|
/// Print and rewrite the committed baseline from what was measured.
|
||||||
|
Record,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_help() {
|
||||||
|
println!(
|
||||||
|
"\
|
||||||
|
dr-bench — DarkRoom's performance suite (docs/requirements.md §8)
|
||||||
|
|
||||||
|
run measure and print, judging nothing
|
||||||
|
check measure, print, and exit 1 on a violated budget or a regression
|
||||||
|
record measure and rewrite docs/bench-baseline.json from the result
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
--reference this machine is the reference desktop, so machine-sensitive
|
||||||
|
budgets are asserted rather than reported
|
||||||
|
--fixture <dir> where the synthetic 50k catalog lives (or $DR_BENCH_DIR)
|
||||||
|
--baseline <file> the committed numbers (default docs/bench-baseline.json)
|
||||||
|
--lanes <n> sweep lanes for the thumbnail row (default: CPU threads)
|
||||||
|
--thumbnails <n> images in the thumbnail row (default 1200)
|
||||||
|
|
||||||
|
Build it in release. A debug build measures the compiler, not the pipeline —
|
||||||
|
see the module documentation and core/dr-gpu/examples/frame_budget.rs."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The run
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn measure_and_report(
|
||||||
|
fixture_dir: &std::path::Path,
|
||||||
|
baseline_path: &std::path::Path,
|
||||||
|
reference: bool,
|
||||||
|
lanes: usize,
|
||||||
|
thumbnail_count: usize,
|
||||||
|
mode: Mode,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let machine = baseline::machine_id();
|
||||||
|
println!("DarkRoom benchmark suite — the half that needs no GPU");
|
||||||
|
println!("machine {machine}");
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
println!(
|
||||||
|
"profile DEBUG — every figure below is several times worse than the \
|
||||||
|
product's. Rerun with --release."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("profile release");
|
||||||
|
}
|
||||||
|
|
||||||
|
let (fx, built) = fixture::build(fixture_dir, SEED, IMAGES)?;
|
||||||
|
println!(
|
||||||
|
"fixture {} images, {} sources at {}x{}, seed {}, catalog {:.1} MB{}",
|
||||||
|
fx.stamp.images,
|
||||||
|
fx.stamp.sources,
|
||||||
|
fx.stamp.preview_width,
|
||||||
|
fx.stamp.preview_height,
|
||||||
|
fx.stamp.seed,
|
||||||
|
fx.catalog_bytes as f64 / 1e6,
|
||||||
|
if built { " (built just now)" } else { "" }
|
||||||
|
);
|
||||||
|
println!(" {}", fx.dir.display());
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Memory first, and in its own process. See `memory` for why: measuring
|
||||||
|
// RSS after the thumbnail sweep would report the sweep's high-water mark
|
||||||
|
// wearing the catalog's name.
|
||||||
|
let rss = match memory::in_a_fresh_process(&fx.catalog) {
|
||||||
|
Ok(rss) => Some(rss),
|
||||||
|
Err(e) => {
|
||||||
|
println!("memory unavailable: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let open = catalog_open::measure(&fx.catalog, WINDOWS)?;
|
||||||
|
let thumbs = thumbnails::measure(&fx.sources, &fx.thumbs, thumbnail_count, lanes)?;
|
||||||
|
let exports = exporting::measure()?;
|
||||||
|
|
||||||
|
print_details(&open, &thumbs, &exports, rss);
|
||||||
|
|
||||||
|
let values = collect(&open, &thumbs, &exports, rss);
|
||||||
|
let mut base = Baseline::load(baseline_path)?;
|
||||||
|
|
||||||
|
if mode == Mode::Record {
|
||||||
|
if !reference {
|
||||||
|
println!(
|
||||||
|
"note recording from a machine that has not declared itself the \
|
||||||
|
reference desktop.\n §8 records the reference desktop's numbers; \
|
||||||
|
this overwrites them."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (key, value) in &values {
|
||||||
|
if let Some(metric) = base.metrics.get_mut(key) {
|
||||||
|
metric.recorded = Some(*value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base.recorded_on = Some(machine);
|
||||||
|
base.recorded_at_unix = Some(baseline::now_unix());
|
||||||
|
base.fixture = Some(fx.stamp.clone());
|
||||||
|
base.save(baseline_path)?;
|
||||||
|
println!("\nrecorded {}", baseline_path.display());
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let same_machine = base.recorded_on.as_deref() == Some(machine.as_str());
|
||||||
|
let comparable = base.fixture.as_ref().is_some_and(|f| *f == fx.stamp);
|
||||||
|
let cx = Judging {
|
||||||
|
reference,
|
||||||
|
same_machine: same_machine && comparable,
|
||||||
|
tolerance: base.tolerance,
|
||||||
|
};
|
||||||
|
let failures = print_verdict(&base, &values, cx, &machine, comparable);
|
||||||
|
|
||||||
|
if failures.is_empty() {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
for line in &failures {
|
||||||
|
println!("FAIL {line}");
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
" {} gate(s) failed. docs/benchmarks.md says what each metric measures and\n \
|
||||||
|
docs/bench-baseline.json holds the numbers these used to be.",
|
||||||
|
failures.len()
|
||||||
|
);
|
||||||
|
Ok(mode != Mode::Gate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The metric keys, and the measurement each one reads.
|
||||||
|
///
|
||||||
|
/// One place, so the report, the gate and the recorded file cannot disagree
|
||||||
|
/// about what `catalog_open_ms` means.
|
||||||
|
fn collect(
|
||||||
|
open: &catalog_open::Open,
|
||||||
|
thumbs: &thumbnails::Throughput,
|
||||||
|
exports: &[exporting::EncodeRun],
|
||||||
|
rss: Option<memory::Rss>,
|
||||||
|
) -> BTreeMap<String, f64> {
|
||||||
|
let mut v = BTreeMap::new();
|
||||||
|
v.insert("catalog_open_ms".to_string(), open.cold_ms);
|
||||||
|
v.insert("catalog_open_warm_ms".to_string(), open.warm_ms);
|
||||||
|
v.insert("catalog_window_p99_ms".to_string(), open.window_ms.p99);
|
||||||
|
v.insert("catalog_filtered_ms".to_string(), open.filtered_ms);
|
||||||
|
let ips = thumbs.images_per_second;
|
||||||
|
v.insert("thumbnail_throughput_ips".to_string(), ips);
|
||||||
|
v.insert(
|
||||||
|
"thumbnail_per_image_p99_ms".to_string(),
|
||||||
|
thumbs.per_image.p99,
|
||||||
|
);
|
||||||
|
for row in exports {
|
||||||
|
v.insert(row.key.to_string(), row.times.p99);
|
||||||
|
}
|
||||||
|
if let Some(rss) = rss {
|
||||||
|
v.insert("catalog_idle_rss_mb".to_string(), rss.now_mb());
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The report
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn print_details(
|
||||||
|
open: &catalog_open::Open,
|
||||||
|
thumbs: &thumbnails::Throughput,
|
||||||
|
exports: &[exporting::EncodeRun],
|
||||||
|
rss: Option<memory::Rss>,
|
||||||
|
) {
|
||||||
|
println!("Opening the catalog (NFR-P1, and R2's second sentence)");
|
||||||
|
println!(
|
||||||
|
" {:>10.1} ms open, count, first window and timeline — cold, first \
|
||||||
|
connection of the process",
|
||||||
|
open.cold_ms
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {:>10.1} ms the same four calls on a second connection",
|
||||||
|
open.warm_ms
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {:>10.1} ms Catalog::open alone (connect, migrate, backfill)",
|
||||||
|
open.open_only_ms
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {:>10.1} ms count and first window under a rating filter",
|
||||||
|
open.filtered_ms
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {:>10.2} ms one 400-row window at a random offset, p99 of {WINDOWS} \
|
||||||
|
(p50 {:.2}, max {:.2})",
|
||||||
|
open.window_ms.p99, open.window_ms.p50, open.window_ms.max
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {} images, {} monthly timeline buckets",
|
||||||
|
open.images, open.buckets
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("Thumbnails on the embedded preview path (NFR-P3: >= 100 img/s)");
|
||||||
|
println!(
|
||||||
|
" {:>10.1} img/s over {} images on {} lanes, {:.2} s of wall clock",
|
||||||
|
thumbs.images_per_second,
|
||||||
|
thumbs.images,
|
||||||
|
thumbs.lanes,
|
||||||
|
thumbs.wall_ms / 1e3
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {:>10.2} ms per image on its lane, p99 (p50 {:.2}, max {:.2})",
|
||||||
|
thumbs.per_image.p99, thumbs.per_image.p50, thumbs.per_image.max
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {} stored, {} failed",
|
||||||
|
thumbs.stored, thumbs.failed
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
println!(" The bytes are in memory before the clock starts, so this is CPU");
|
||||||
|
println!(" throughput with the fetch already paid for. That is what the target's");
|
||||||
|
println!(" \"embedded preview path\" names; it is not a claim about a remote library.");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("Exporting 24 MP — the encode half only (NFR-P7 is the whole chain)");
|
||||||
|
println!(
|
||||||
|
" {:>14} {:>11} {:>9} {:>9} {:>9}",
|
||||||
|
"sizing", "output", "p50", "p99", "file"
|
||||||
|
);
|
||||||
|
for row in exports {
|
||||||
|
println!(
|
||||||
|
" {:>14} {:>5}x{:<5} {:>7.1}ms {:>7.1}ms {:>7.1}MB",
|
||||||
|
row.label,
|
||||||
|
row.width,
|
||||||
|
row.height,
|
||||||
|
row.times.p50,
|
||||||
|
row.times.p99,
|
||||||
|
row.bytes as f64 / 1e6
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
println!(" No GPU render is in these figures, so they cannot pass NFR-P7 — only fail");
|
||||||
|
println!(" it. See tools/bench/src/exporting.rs for why that is still worth gating.");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("Idle memory with the catalog open (NFR-P8's catalog share)");
|
||||||
|
match rss {
|
||||||
|
Some(rss) => {
|
||||||
|
println!(
|
||||||
|
" {:>10.1} MB resident after opening 50k and scrolling 10k rows",
|
||||||
|
rss.now_mb()
|
||||||
|
);
|
||||||
|
println!(" {:>10.1} MB peak for that process", rss.peak_mb());
|
||||||
|
println!();
|
||||||
|
println!(" RSS is exclusive of device-local GPU memory and this process has no");
|
||||||
|
println!(" toolkit, no adapter and no decode cache — so it is the catalog layer's");
|
||||||
|
println!(" share of NFR-P8's 500 MB, not NFR-P8. tools/bench/src/memory.rs holds");
|
||||||
|
println!(" the answer to the question §4.1 asks, and the change it recommends.");
|
||||||
|
}
|
||||||
|
None => println!(" not measured on this platform"),
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print the gate table and return the failures, one line each.
|
||||||
|
fn print_verdict(
|
||||||
|
base: &Baseline,
|
||||||
|
values: &BTreeMap<String, f64>,
|
||||||
|
cx: Judging,
|
||||||
|
machine: &str,
|
||||||
|
comparable_fixture: bool,
|
||||||
|
) -> Vec<String> {
|
||||||
|
println!("Against docs/bench-baseline.json");
|
||||||
|
match (&base.recorded_on, cx.same_machine) {
|
||||||
|
(None, _) => println!(
|
||||||
|
" No baseline has been recorded yet. Budgets are still gated; drift is not.\n \
|
||||||
|
Run `dr-bench record --reference` on the reference desktop and commit the diff."
|
||||||
|
),
|
||||||
|
(Some(on), true) => println!(" Recorded on {on} — drift below is a verdict."),
|
||||||
|
(Some(on), false) if !comparable_fixture => println!(
|
||||||
|
" Recorded on {on} against a different fixture; drift below is information only."
|
||||||
|
),
|
||||||
|
(Some(on), false) => println!(
|
||||||
|
" Recorded on {on}, and this is {machine}. Drift below is information, not a verdict."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if !cx.reference {
|
||||||
|
println!(
|
||||||
|
" Not the reference desktop (--reference), so machine-sensitive budgets are\n \
|
||||||
|
reported rather than asserted — §8 names the reference desktop, not CI."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
" {:<30} {:>12} {:>13} {:>12} {:>8} verdict",
|
||||||
|
"metric", "measured", "budget", "baseline", "drift"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
for (key, measured) in values {
|
||||||
|
let Some(metric) = base.metrics.get(key) else {
|
||||||
|
println!(
|
||||||
|
" {key:<30} {measured:>12.2} {:>13} {:>12} {:>8} not in the baseline",
|
||||||
|
"—", "—", "—"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let j = metric.judge(*measured, cx);
|
||||||
|
let budget = match (metric.budget, metric.direction) {
|
||||||
|
(None, _) => "—".to_string(),
|
||||||
|
(Some(b), baseline::Direction::LowerIsBetter) => format!("< {b:.1}"),
|
||||||
|
(Some(b), baseline::Direction::HigherIsBetter) => format!("> {b:.1}"),
|
||||||
|
};
|
||||||
|
let recorded = match metric.recorded {
|
||||||
|
Some(r) => format!("{r:.2}"),
|
||||||
|
None => "—".to_string(),
|
||||||
|
};
|
||||||
|
let drift = match j.drift {
|
||||||
|
Some(d) => format!("{:+.1}%", d * 100.0),
|
||||||
|
None => "—".to_string(),
|
||||||
|
};
|
||||||
|
let mut verdict = String::new();
|
||||||
|
if j.over_budget {
|
||||||
|
verdict.push_str("OVER BUDGET ");
|
||||||
|
failures.push(format!(
|
||||||
|
"{key} is {measured:.2} {}, past {budget} ({})",
|
||||||
|
metric.unit, metric.requirement
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if j.regressed {
|
||||||
|
verdict.push_str("REGRESSED ");
|
||||||
|
failures.push(format!(
|
||||||
|
"{key} drifted {drift} against the baseline, past the {:.0}% tolerance",
|
||||||
|
cx.tolerance * 100.0
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if verdict.is_empty() {
|
||||||
|
verdict.push_str(if j.budget_deferred {
|
||||||
|
"ok (budget deferred)"
|
||||||
|
} else {
|
||||||
|
"ok"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
println!(" {key:<30} {measured:>12.2} {budget:>13} {recorded:>12} {drift:>8} {verdict}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A metric in the file that nothing measured is a harness that has drifted
|
||||||
|
// from its own record, and is worth saying out loud rather than leaving as
|
||||||
|
// a row that quietly stopped appearing.
|
||||||
|
for key in base.metrics.keys() {
|
||||||
|
if !values.contains_key(key) {
|
||||||
|
println!(" {key:<30} {:>12} measured nothing this run", "—");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
failures
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
//! Idle memory with a 50k catalog open — and the question NFR-P8 leaves open.
|
||||||
|
//!
|
||||||
|
//! # The question §4.1 asks, answered
|
||||||
|
//!
|
||||||
|
//! §4.1 says of NFR-P8: *"must state whether it measures RSS inclusive or
|
||||||
|
//! exclusive of GPU allocations, and whether it holds after SQLite's page cache
|
||||||
|
//! warms on a 50k catalog."* Both halves have an answer, and neither is
|
||||||
|
//! flattering.
|
||||||
|
//!
|
||||||
|
//! **On GPU memory: what this reports is RSS, and RSS is exclusive of
|
||||||
|
//! device-local GPU allocations.** A Vulkan allocation in device-local heap
|
||||||
|
//! never enters the process's address space, so no counter under
|
||||||
|
//! `/proc/self/status` can see it; what *does* land in RSS is the host-visible
|
||||||
|
//! side — staging buffers, mapped upload rings, the read-back `AdjustPass`
|
||||||
|
//! performs on export — and the driver's own resident pages. So "RSS < 500 MB"
|
||||||
|
//! is not one budget, it is two questions wearing one number, and a build that
|
||||||
|
//! kept RSS at 400 MB while holding 3 GB of textures would pass it.
|
||||||
|
//!
|
||||||
|
//! The recommendation this measurement exists to support: **NFR-P8 should be
|
||||||
|
//! restated as two figures** — host RSS exclusive of device-local memory, and
|
||||||
|
//! a separate VRAM ceiling read from the adapter — because the second is the
|
||||||
|
//! one that decides whether the application survives beside a browser on an
|
||||||
|
//! 8 GB card, and nothing in this repository currently measures it.
|
||||||
|
//!
|
||||||
|
//! **On the page cache: warm.** The probe runs the queries before it reads the
|
||||||
|
//! counter, so SQLite's page cache holds the b-tree pages a grid scroll
|
||||||
|
//! touches. That is the right side to err on — a figure taken before the cache
|
||||||
|
//! warms would understate a steady-state library — and it is why the probe
|
||||||
|
//! scrolls rather than opening and stopping.
|
||||||
|
//!
|
||||||
|
//! # Why this is a subprocess
|
||||||
|
//!
|
||||||
|
//! RSS is a high-water-influenced property of a *process*, not of a function.
|
||||||
|
//! Building a 50k fixture allocates hundreds of megabytes; decoding thumbnails
|
||||||
|
//! allocates more; the allocator returns some of it to the OS and keeps the
|
||||||
|
//! rest. Measuring after any of that would report the harness's history rather
|
||||||
|
//! than the catalog's cost. So the probe is a fresh process that opens the
|
||||||
|
//! catalog, does the grid's work, reads its own counters and exits.
|
||||||
|
//!
|
||||||
|
//! # What this cannot certify, said plainly
|
||||||
|
//!
|
||||||
|
//! Not NFR-P8. The requirement is about the *application* at idle — Slint, the
|
||||||
|
//! wgpu device, the font stack, the decode cache and the catalog together — and
|
||||||
|
//! this process contains only the last of those. No requirement tag in this
|
||||||
|
//! crate names NFR-P8, for that reason — and see `exporting.rs` for why that
|
||||||
|
//! sentence avoids spelling the tag out.
|
||||||
|
//!
|
||||||
|
//! What it is, is the catalog layer's share, measured rather than guessed. The
|
||||||
|
//! decision NFR-P8 actually needs — how much of the 500 MB belongs to the
|
||||||
|
//! catalog and how much to everything above it — is a decision somebody has to
|
||||||
|
//! take, and taking it against a recorded number is better than taking it
|
||||||
|
//! against an estimate. That is what this records. Until it is taken, the
|
||||||
|
//! metric carries no budget and gates only against its own baseline.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use dr_catalog::{Catalog, Granularity, Query};
|
||||||
|
|
||||||
|
/// Rows fetched per window while the probe scrolls. The same 400
|
||||||
|
/// [`crate::catalog_open`] uses, for the same reason.
|
||||||
|
const WINDOW: usize = 400;
|
||||||
|
|
||||||
|
/// Windows the probe pages through before reading the counter.
|
||||||
|
///
|
||||||
|
/// Twenty-five is ten thousand rows: enough that SQLite's page cache holds a
|
||||||
|
/// realistic working set and that any per-window leak would be visible, and
|
||||||
|
/// far short of the whole library, which FR-CAT-4 forbids holding anyway.
|
||||||
|
const WINDOWS: usize = 25;
|
||||||
|
|
||||||
|
/// A fixed clock, for the reason `catalog_open`'s `NOW` gives: nothing here
|
||||||
|
/// should depend on the day it runs.
|
||||||
|
const NOW: i64 = 2_000_000_000;
|
||||||
|
|
||||||
|
/// Resident memory, in kilobytes, as Linux reports it.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Rss {
|
||||||
|
/// `VmRSS`: resident now.
|
||||||
|
pub now_kb: u64,
|
||||||
|
/// `VmHWM`: the peak this process reached. Reported alongside because a
|
||||||
|
/// process that touched 900 MB and gave it back is not idling at 200 MB in
|
||||||
|
/// any sense a user would recognise — the pages came from somewhere.
|
||||||
|
pub peak_kb: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rss {
|
||||||
|
pub fn now_mb(&self) -> f64 {
|
||||||
|
self.now_kb as f64 / 1024.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn peak_mb(&self) -> f64 {
|
||||||
|
self.peak_kb as f64 / 1024.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read this process's own counters.
|
||||||
|
///
|
||||||
|
/// `None` anywhere without a Linux-shaped `/proc` — including Android, where
|
||||||
|
/// the file exists but a benchmark does not run, and macOS, where it does not.
|
||||||
|
/// Returning `None` rather than zero is deliberate: a memory figure of zero
|
||||||
|
/// would be reported as an excellent result.
|
||||||
|
pub fn of_this_process() -> Option<Rss> {
|
||||||
|
let status = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||||
|
let mut now = None;
|
||||||
|
let mut peak = None;
|
||||||
|
for line in status.lines() {
|
||||||
|
if let Some(rest) = line.strip_prefix("VmRSS:") {
|
||||||
|
now = rest.split_whitespace().next()?.parse::<u64>().ok();
|
||||||
|
} else if let Some(rest) = line.strip_prefix("VmHWM:") {
|
||||||
|
peak = rest.split_whitespace().next()?.parse::<u64>().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Rss {
|
||||||
|
now_kb: now?,
|
||||||
|
peak_kb: peak?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The probe: open the catalog, do what the grid does, print the counters.
|
||||||
|
///
|
||||||
|
/// Stdout is one line of `key=value` pairs rather than JSON, because the only
|
||||||
|
/// reader is [`in_a_fresh_process`] and a format a human can read in a log is
|
||||||
|
/// worth more here than one a parser prefers.
|
||||||
|
pub fn probe(catalog_path: &Path) -> Result<()> {
|
||||||
|
let catalog = Catalog::open(catalog_path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("opening {} : {e}", catalog_path.display()))?;
|
||||||
|
let q = Query::default();
|
||||||
|
|
||||||
|
let images = catalog.count(&q, NOW)?;
|
||||||
|
let buckets = catalog.timeline(&q, Granularity::Month, NOW)?.len();
|
||||||
|
|
||||||
|
// Scroll, keeping only the window in hand — which is what the grid does,
|
||||||
|
// and what FR-CAT-4 requires it to do. If this ever starts costing memory
|
||||||
|
// proportional to how far the user scrolled, that is the bug this figure
|
||||||
|
// exists to catch.
|
||||||
|
let mut rows = 0usize;
|
||||||
|
let span = images.saturating_sub(WINDOW).max(1);
|
||||||
|
for i in 0..WINDOWS {
|
||||||
|
let start = (i * span) / WINDOWS.max(1);
|
||||||
|
rows = catalog.window(&q, start..start + WINDOW, NOW)?.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(rss) = of_this_process() else {
|
||||||
|
anyhow::bail!("no /proc/self/status on this platform; RSS cannot be read");
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"rss_kb={} peak_kb={} images={images} buckets={buckets} last_window={rows}",
|
||||||
|
rss.now_kb, rss.peak_kb
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run [`probe`] in a fresh copy of this executable and read back its counters.
|
||||||
|
pub fn in_a_fresh_process(catalog_path: &Path) -> Result<Rss> {
|
||||||
|
let exe = std::env::current_exe()?;
|
||||||
|
let output = std::process::Command::new(&exe)
|
||||||
|
.arg("memory-probe")
|
||||||
|
.arg(catalog_path)
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"the memory probe exited with {}: {}",
|
||||||
|
output.status,
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let field = |key: &str| -> Option<u64> {
|
||||||
|
text.split_whitespace()
|
||||||
|
.find_map(|pair| pair.strip_prefix(key))
|
||||||
|
.and_then(|v| v.parse::<u64>().ok())
|
||||||
|
};
|
||||||
|
let (Some(now_kb), Some(peak_kb)) = (field("rss_kb="), field("peak_kb=")) else {
|
||||||
|
anyhow::bail!(
|
||||||
|
"the memory probe printed something unreadable: {}",
|
||||||
|
text.trim()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
Ok(Rss { now_kb, peak_kb })
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
//! Ranking a set of samples, the way `dr-gpu`'s frame budget ranks them.
|
||||||
|
//!
|
||||||
|
//! Copied in spirit rather than shared, because the two live in different
|
||||||
|
//! dependency worlds — `core/dr-gpu/examples/frame_budget.rs` is an example
|
||||||
|
//! inside a crate this one deliberately does not depend on (see `Cargo.toml`).
|
||||||
|
//! The arithmetic is identical on purpose: two percentile definitions in one
|
||||||
|
//! repository is how two benchmarks come to disagree about the same machine.
|
||||||
|
//!
|
||||||
|
//! # Nearest-rank, not an interpolating definition
|
||||||
|
//!
|
||||||
|
//! The samples *are* the population. There is no distribution being estimated
|
||||||
|
//! here, only a set of catalog opens or thumbnail encodes that either happened
|
||||||
|
//! inside the target or did not. At 100 samples the 99th percentile is the
|
||||||
|
//! second-worst, which is the honest reading of "one bad one in a hundred is
|
||||||
|
//! one too many" without letting a single scheduler hiccup on an unrelated
|
||||||
|
//! process decide the verdict.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Nearest-rank percentiles over a set of samples, in the caller's unit.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Percentiles {
|
||||||
|
pub p50: f64,
|
||||||
|
pub p99: f64,
|
||||||
|
pub max: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Percentiles {
|
||||||
|
/// Rank `samples`. Panics on an empty set, which is a harness bug rather
|
||||||
|
/// than a measurement: a row with nothing in it must not print a zero that
|
||||||
|
/// reads like a very fast result.
|
||||||
|
pub fn of(mut samples: Vec<f64>) -> Self {
|
||||||
|
assert!(
|
||||||
|
!samples.is_empty(),
|
||||||
|
"percentiles of an empty sample set — the measurement produced nothing"
|
||||||
|
);
|
||||||
|
samples.sort_by(f64::total_cmp);
|
||||||
|
let rank = |p: f64| {
|
||||||
|
let n = samples.len();
|
||||||
|
let i = ((p * n as f64).ceil() as usize).clamp(1, n) - 1;
|
||||||
|
samples[i]
|
||||||
|
};
|
||||||
|
Percentiles {
|
||||||
|
p50: rank(0.50),
|
||||||
|
p99: rank(0.99),
|
||||||
|
max: samples[samples.len() - 1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A duration in milliseconds, which is the unit every timing here is stated
|
||||||
|
/// in. One spelling, so no row is accidentally in seconds.
|
||||||
|
pub fn ms(d: Duration) -> f64 {
|
||||||
|
d.as_secs_f64() * 1e3
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A deterministic generator, so a fixture is reproducible from its seed.
|
||||||
|
///
|
||||||
|
/// SplitMix64. Chosen because it is eight lines, has no dependency, and passes
|
||||||
|
/// the only test that matters here — that the same seed produces the same
|
||||||
|
/// catalog on the reference desktop and on the CI runner, so a number measured
|
||||||
|
/// in one place describes the same workload as a number measured in the other.
|
||||||
|
/// Nothing cryptographic depends on it.
|
||||||
|
pub struct Rng(u64);
|
||||||
|
|
||||||
|
impl Rng {
|
||||||
|
pub fn new(seed: u64) -> Self {
|
||||||
|
Rng(seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_u64(&mut self) -> u64 {
|
||||||
|
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||||
|
let mut z = self.0;
|
||||||
|
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||||
|
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||||
|
z ^ (z >> 31)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A value in `0..n`. Modulo-biased, which does not matter for a fixture:
|
||||||
|
/// nothing here is a statistical test, only a spread of plausible values.
|
||||||
|
pub fn below(&mut self, n: u64) -> u64 {
|
||||||
|
self.next_u64() % n.max(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_ninety_ninth_of_a_hundred_is_the_second_worst() {
|
||||||
|
// The property the whole suite's verdict rests on. Off by one here and
|
||||||
|
// every threshold is judged against the worst sample instead.
|
||||||
|
let samples: Vec<f64> = (1..=100).map(|n| n as f64).collect();
|
||||||
|
let p = Percentiles::of(samples);
|
||||||
|
assert_eq!(p.p99, 99.0);
|
||||||
|
assert_eq!(p.max, 100.0);
|
||||||
|
assert_eq!(p.p50, 50.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_single_sample_ranks_as_itself() {
|
||||||
|
// A row measured once — a cold catalog open — must not divide by zero
|
||||||
|
// or index off the end.
|
||||||
|
let p = Percentiles::of(vec![7.5]);
|
||||||
|
assert_eq!((p.p50, p.p99, p.max), (7.5, 7.5, 7.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_same_seed_gives_the_same_sequence() {
|
||||||
|
// Reproducibility from a seed is what makes a committed baseline mean
|
||||||
|
// anything: two runs must describe the same catalog.
|
||||||
|
let mut a = Rng::new(20_260_829);
|
||||||
|
let mut b = Rng::new(20_260_829);
|
||||||
|
let mut c = Rng::new(20_260_830);
|
||||||
|
let first: Vec<u64> = (0..8).map(|_| a.next_u64()).collect();
|
||||||
|
let same: Vec<u64> = (0..8).map(|_| b.next_u64()).collect();
|
||||||
|
let other: Vec<u64> = (0..8).map(|_| c.next_u64()).collect();
|
||||||
|
assert_eq!(first, same);
|
||||||
|
assert_ne!(first, other);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
//! TRACES: NFR-P3
|
||||||
|
//! Thumbnail throughput on the embedded preview path.
|
||||||
|
//!
|
||||||
|
//! NFR-P3 asks for **≥ 100 images per second** on the reference desktop
|
||||||
|
//! through the embedded preview path, and ≥ 25 on a mid-range Android device.
|
||||||
|
//! Nothing had ever counted.
|
||||||
|
//!
|
||||||
|
//! # What is timed, and why it is not the sweep itself
|
||||||
|
//!
|
||||||
|
//! `ui/dr-ui/src/library.rs`'s [`spawn_thumbnail_sweep`] is the whole-library
|
||||||
|
//! pass, and it is the machinery this mirrors: a chunk at a time, lanes owning
|
||||||
|
//! disjoint slices, every lane decoding and encoding on its own, and the
|
||||||
|
//! single thread that owns the store writing the finished chunk. That shape is
|
||||||
|
//! reproduced here because it is the shape that decides the number — where the
|
||||||
|
//! parallelism is, and where the one lock is.
|
||||||
|
//!
|
||||||
|
//! It is *mirrored* rather than called, for a reason worth stating plainly:
|
||||||
|
//! that function takes a `RemoteBackend` and spends most of its wall clock in
|
||||||
|
//! WebDAV round trips. Calling it from a benchmark would need a Nextcloud
|
||||||
|
//! server, and what it would then measure is somebody's network. The per-image
|
||||||
|
//! work is identical either way — `dr_decode::decode_jpeg`,
|
||||||
|
//! `Preview::downscale_to`, `Preview::apply_orientation`,
|
||||||
|
//! `dr_thumbs::encode_rgba`, `ThumbStore::put` — and that work is what a target
|
||||||
|
//! written in images per second is about.
|
||||||
|
//!
|
||||||
|
//! So: **the bytes are already in memory when the clock starts.** This is CPU
|
||||||
|
//! throughput for the preview path with the fetch paid for, which is what a
|
||||||
|
//! local library gives you and what the target's "embedded preview path"
|
||||||
|
//! names. It is not a claim about a remote library, whose ceiling is latency
|
||||||
|
//! and which [`spawn_thumbnail_sweep`] exists to hide rather than to beat.
|
||||||
|
//!
|
||||||
|
//! # The plain-JPEG branch, deliberately
|
||||||
|
//!
|
||||||
|
//! `fetch_preview` has two arms: a plain JPEG is its own preview, and anything
|
||||||
|
//! else is located inside the container first. The fixture's files take the
|
||||||
|
//! first arm, so `locate_preview` is not in the measured span. That is honest
|
||||||
|
//! for two reasons — a library of camera JPEGs is a real library and takes
|
||||||
|
//! exactly this path, and for a RAW the located preview *is* a JPEG of about
|
||||||
|
//! this size, so what changes is a header walk of a few microseconds against a
|
||||||
|
//! decode of several milliseconds. What is genuinely not measured is the
|
||||||
|
//! container parse of an exotic format, and nothing here pretends otherwise.
|
||||||
|
//!
|
||||||
|
//! [`spawn_thumbnail_sweep`]: ../../dr_ui/library/fn.spawn_thumbnail_sweep.html
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use dr_thumbs::{ThumbSize, ThumbStore, Thumbnail};
|
||||||
|
use dr_types::Orientation;
|
||||||
|
|
||||||
|
use crate::stats::{ms, Percentiles};
|
||||||
|
|
||||||
|
/// Images per chunk handed back to the thread that owns the store.
|
||||||
|
///
|
||||||
|
/// 96, which is `SWEEP_CHUNK` in `library.rs`. Copied rather than chosen: the
|
||||||
|
/// point of this row is to describe the sweep's behaviour, and a different
|
||||||
|
/// chunk size would move the ratio of lane work to store work.
|
||||||
|
const CHUNK: usize = 96;
|
||||||
|
|
||||||
|
/// The size class the whole-library pass fills.
|
||||||
|
///
|
||||||
|
/// Grid only, which is `SWEEP_THUMB_SIZE`. The large class is four times the
|
||||||
|
/// transfer for a detail only a zoomed cell asks for, so the sweep does not
|
||||||
|
/// produce it and neither does this.
|
||||||
|
const SIZE: ThumbSize = ThumbSize::Grid;
|
||||||
|
|
||||||
|
/// What a measured sweep produced.
|
||||||
|
pub struct Throughput {
|
||||||
|
pub images: usize,
|
||||||
|
pub lanes: usize,
|
||||||
|
pub wall_ms: f64,
|
||||||
|
pub images_per_second: f64,
|
||||||
|
/// Per image, on the lane that produced it: decode, downscale, orient,
|
||||||
|
/// encode. Not the store write, which happens elsewhere by design.
|
||||||
|
pub per_image: Percentiles,
|
||||||
|
/// Thumbnails that reached the store.
|
||||||
|
pub stored: usize,
|
||||||
|
/// Images whose preview would not decode. Any non-zero is a broken
|
||||||
|
/// fixture, not a slow one.
|
||||||
|
pub failed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One lane's output: what it made, what each one cost, and what it dropped.
|
||||||
|
struct Lane {
|
||||||
|
made: Vec<(u64, Thumbnail)>,
|
||||||
|
times: Vec<f64>,
|
||||||
|
failed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sweep `images` thumbnails from `sources`, `lanes` at a time.
|
||||||
|
///
|
||||||
|
/// `store_dir` is emptied first. Re-storing an id that is already present is
|
||||||
|
/// an `UPDATE` in place rather than an insert (see `ThumbStore::put`), and a
|
||||||
|
/// run that measured updates would not be measuring the pass this describes —
|
||||||
|
/// the sweep's work list is by construction what the store does *not* have.
|
||||||
|
pub fn measure(
|
||||||
|
sources: &[PathBuf],
|
||||||
|
store_dir: &Path,
|
||||||
|
images: usize,
|
||||||
|
lanes: usize,
|
||||||
|
) -> Result<Throughput> {
|
||||||
|
anyhow::ensure!(!sources.is_empty(), "no fixture sources to sweep");
|
||||||
|
anyhow::ensure!(lanes > 0, "a sweep needs at least one lane");
|
||||||
|
|
||||||
|
let bytes: Vec<Vec<u8>> = sources
|
||||||
|
.iter()
|
||||||
|
.map(|p| std::fs::read(p).with_context(|| format!("reading {}", p.display())))
|
||||||
|
.collect::<Result<_>>()?;
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(store_dir);
|
||||||
|
let mut store = ThumbStore::open(store_dir)
|
||||||
|
.map_err(|e| anyhow::anyhow!("opening the bench thumbnail store: {e}"))?;
|
||||||
|
|
||||||
|
// Warm-up: every source decoded once, discarded. The first decode of a
|
||||||
|
// file faults in its Huffman tables and grows the allocator's arenas to
|
||||||
|
// the size a 1620x1080 RGBA buffer needs, and neither recurs across a
|
||||||
|
// sweep of thousands.
|
||||||
|
let warm_failures = Lane::run(&bytes, (0..bytes.len()).collect()).failed;
|
||||||
|
anyhow::ensure!(
|
||||||
|
warm_failures == 0,
|
||||||
|
"{warm_failures} of the {} fixture sources would not decode",
|
||||||
|
bytes.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut samples: Vec<f64> = Vec::with_capacity(images);
|
||||||
|
let mut stored = 0usize;
|
||||||
|
let mut failed = 0usize;
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
for chunk_start in (0..images).step_by(CHUNK) {
|
||||||
|
let chunk_end = (chunk_start + CHUNK).min(images);
|
||||||
|
// Each lane takes every `lanes`-th image of the chunk, which is how
|
||||||
|
// `spawn_thumbnail_sweep` splits one: disjoint slices, nothing shared,
|
||||||
|
// no lock.
|
||||||
|
let work: Vec<Vec<usize>> = (0..lanes)
|
||||||
|
.map(|lane| (chunk_start..chunk_end).skip(lane).step_by(lanes).collect())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let source_slice: &[Vec<u8>] = &bytes;
|
||||||
|
let produced: Vec<Lane> = std::thread::scope(|scope| {
|
||||||
|
let handles: Vec<_> = work
|
||||||
|
.into_iter()
|
||||||
|
.map(|lane| scope.spawn(move || Lane::run(source_slice, lane)))
|
||||||
|
.collect();
|
||||||
|
handles
|
||||||
|
.into_iter()
|
||||||
|
.map(|h| h.join().expect("a sweep lane panicked"))
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
// The store is `&mut` and single-writer, so the chunk is written here
|
||||||
|
// and not on the lanes. This is the sweep's own discipline and it is
|
||||||
|
// part of the number: if the store were the bottleneck, no amount of
|
||||||
|
// lane parallelism would help and the row would say so.
|
||||||
|
for lane in produced {
|
||||||
|
samples.extend(lane.times);
|
||||||
|
failed += lane.failed;
|
||||||
|
for (file_id, thumb) in lane.made {
|
||||||
|
match store.put(file_id, SIZE, &thumb) {
|
||||||
|
Ok(_) => stored += 1,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("storing bench thumbnail {file_id}: {e}");
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let wall = started.elapsed();
|
||||||
|
|
||||||
|
anyhow::ensure!(
|
||||||
|
failed == 0,
|
||||||
|
"{failed} of {images} thumbnails failed; the throughput below would be \
|
||||||
|
measuring how fast this gives up"
|
||||||
|
);
|
||||||
|
|
||||||
|
let wall_ms = ms(wall);
|
||||||
|
Ok(Throughput {
|
||||||
|
images,
|
||||||
|
lanes,
|
||||||
|
wall_ms,
|
||||||
|
images_per_second: images as f64 / wall.as_secs_f64().max(f64::MIN_POSITIVE),
|
||||||
|
per_image: Percentiles::of(samples),
|
||||||
|
stored,
|
||||||
|
failed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Lane {
|
||||||
|
/// Turn each of `work`'s previews into a stored thumbnail's worth of bytes.
|
||||||
|
///
|
||||||
|
/// The four calls, in the order `fetch_preview` and `encode_preview` make
|
||||||
|
/// them. `is_complete_jpeg` is included because it is in the real path and
|
||||||
|
/// because leaving it out would be the sort of small omission that turns a
|
||||||
|
/// measurement into an estimate: a truncated JPEG decodes "successfully"
|
||||||
|
/// into a partial frame, so the check is not optional and its cost is not
|
||||||
|
/// somebody else's.
|
||||||
|
fn run(sources: &[Vec<u8>], work: Vec<usize>) -> Lane {
|
||||||
|
let mut made = Vec::with_capacity(work.len());
|
||||||
|
let mut times = Vec::with_capacity(work.len());
|
||||||
|
let mut failed = 0usize;
|
||||||
|
|
||||||
|
for index in work {
|
||||||
|
let bytes = &sources[index % sources.len()];
|
||||||
|
// The fixture makes every fourth image portrait, so a quarter of
|
||||||
|
// these pay for the permutation `apply_orientation` performs. In a
|
||||||
|
// real library that fraction is whatever the photographer shot.
|
||||||
|
let orientation = if index % 4 == 3 {
|
||||||
|
Orientation::from_exif(6)
|
||||||
|
} else {
|
||||||
|
Orientation::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
if !dr_decode::is_complete_jpeg(bytes) {
|
||||||
|
failed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut preview = match dr_decode::decode_jpeg(bytes) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("bench preview {index}: {e}");
|
||||||
|
failed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
preview.downscale_to(SIZE.edge());
|
||||||
|
preview.apply_orientation(orientation);
|
||||||
|
let rgba = &preview.rgba;
|
||||||
|
let encoded = match dr_thumbs::encode_rgba(preview.width, preview.height, rgba) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("bench thumbnail {index}: {e}");
|
||||||
|
failed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
times.push(ms(started.elapsed()));
|
||||||
|
|
||||||
|
made.push((
|
||||||
|
index as u64 + 1,
|
||||||
|
Thumbnail {
|
||||||
|
width: preview.width,
|
||||||
|
height: preview.height,
|
||||||
|
bytes: encoded,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Lane {
|
||||||
|
made,
|
||||||
|
times,
|
||||||
|
failed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many lanes to sweep with by default.
|
||||||
|
///
|
||||||
|
/// The machine's threads, not `SWEEP_LANES`. The sweep's six are sized for
|
||||||
|
/// *latency* — each image is ~0.6 s of WebDAV round trip and almost no CPU, so
|
||||||
|
/// six in flight is a queue depth rather than a core count. With the bytes
|
||||||
|
/// already in memory the work is purely CPU, and six lanes on a 24-thread
|
||||||
|
/// desktop would report a quarter of the throughput the machine has. Stated
|
||||||
|
/// here rather than buried, because it is the one place this deviates from the
|
||||||
|
/// shape it otherwise copies.
|
||||||
|
pub fn default_lanes() -> usize {
|
||||||
|
std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(1)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//! TRACES: FR-UI-4 | NFR-OPS-1
|
//! TRACES: FR-UI-4
|
||||||
//! Interaction documentation, extracted from the code that implements it.
|
//! Interaction documentation, extracted from the code that implements it.
|
||||||
//!
|
//!
|
||||||
//! # Why this exists at all
|
//! # Why this exists at all
|
||||||
|
|||||||
@@ -19,6 +19,19 @@
|
|||||||
//! count as the numerator is precisely what lets a ratio exceed 100%, since
|
//! count as the numerator is precisely what lets a ratio exceed 100%, since
|
||||||
//! a tag naming a deleted requirement would count as covered. Such tags are
|
//! a tag naming a deleted requirement would count as covered. Such tags are
|
||||||
//! reported as orphans instead.
|
//! reported as orphans instead.
|
||||||
|
//!
|
||||||
|
//! # And why the extractor is fussy about where a tag sits
|
||||||
|
//!
|
||||||
|
//! A third rule, learned the same way. `SOURCE_ROOTS` includes `tools`, so this
|
||||||
|
//! crate scans itself, and the fixtures below demonstrating tag extraction were
|
||||||
|
//! read as tags: R1 — cross-platform output within a bounded tolerance — was
|
||||||
|
//! reported implemented on the strength of two string literals in a unit test.
|
||||||
|
//!
|
||||||
|
//! 3. **A tag is a comment whose first word is `TRACES:`**, not a line in which
|
||||||
|
//! the string appears. See `tag_body`. It is a rule about position rather
|
||||||
|
//! than about string literals, because `schema.rs` writes six real tags
|
||||||
|
//! *inside* string literals — the SQL it embeds is commented with `--` — so
|
||||||
|
//! an extractor that refused string literals would lose more than it saved.
|
||||||
|
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -182,16 +195,43 @@ pub fn is_requirement(id: &str) -> bool {
|
|||||||
REQUIREMENT_TYPES.contains(&type_of(id))
|
REQUIREMENT_TYPES.contains(&type_of(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Comment openers a tag may be introduced by.
|
||||||
|
///
|
||||||
|
/// `--` is here for the SQL embedded in `schema.rs`, which carries real tags
|
||||||
|
/// inside Rust string literals; `#` for YAML; `*` for the continuation lines of
|
||||||
|
/// a block comment.
|
||||||
|
const COMMENT_OPENERS: &[&str] = &["///", "//!", "//", "<!--", "/*", "*", "--", "#"];
|
||||||
|
|
||||||
|
/// The body of a tag comment, if this line is one.
|
||||||
|
///
|
||||||
|
/// **A tag is a comment whose first word is `TRACES:`** — not any line in which
|
||||||
|
/// the string appears. The tool scans `tools/`, which is its own source, so
|
||||||
|
/// without this rule its unit-test fixtures are tags: a one-line literal in
|
||||||
|
/// `context_looks_forward_then_backward` had R1 reported as implemented, and
|
||||||
|
/// the register believed it. That is not a quirk of this crate. `build.rs`
|
||||||
|
/// emits a tag into generated code from a string literal, and any test
|
||||||
|
/// anywhere that exercises the extractor would do the same.
|
||||||
|
///
|
||||||
|
/// The rule does not merely exclude string literals — it cannot tell one from a
|
||||||
|
/// comment, and `schema.rs` proves it must not try, since its `-- TRACES:` tags
|
||||||
|
/// live inside string literals and are entirely real. What it asks instead is
|
||||||
|
/// where on the line the tag sits: a tag written to be read is the first thing
|
||||||
|
/// in its comment, and a tag quoted inside an expression never is.
|
||||||
|
fn tag_body(line: &str) -> Option<&str> {
|
||||||
|
let line = line.trim_start();
|
||||||
|
let after_opener = COMMENT_OPENERS.iter().find_map(|o| line.strip_prefix(o))?;
|
||||||
|
after_opener.trim_start().strip_prefix("TRACES:")
|
||||||
|
}
|
||||||
|
|
||||||
/// Extract every `TRACES:` tag from a source file's text.
|
/// Extract every `TRACES:` tag from a source file's text.
|
||||||
pub fn extract_from_text(text: &str, path: &str) -> Vec<TraceEntry> {
|
pub fn extract_from_text(text: &str, path: &str) -> Vec<TraceEntry> {
|
||||||
let lines: Vec<&str> = text.lines().collect();
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
|
|
||||||
for (idx, line) in lines.iter().enumerate() {
|
for (idx, line) in lines.iter().enumerate() {
|
||||||
let Some(pos) = line.find("TRACES:") else {
|
let Some(tail) = tag_body(line) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let tail = &line[pos + "TRACES:".len()..];
|
|
||||||
let ids = parse_ids(tail);
|
let ids = parse_ids(tail);
|
||||||
if ids.is_empty() {
|
if ids.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
@@ -265,7 +305,6 @@ fn trim_body(line: &str) -> String {
|
|||||||
///
|
///
|
||||||
/// This signature is the fix for the 158% bug: `defined` is required, so there
|
/// This signature is the fix for the 158% bug: `defined` is required, so there
|
||||||
/// is nowhere for a frozen denominator to hide.
|
/// is nowhere for a frozen denominator to hide.
|
||||||
/// TRACES: NFR-OPS-1
|
|
||||||
pub fn compute_coverage(traced: &BTreeSet<String>, defined: &DefinedRequirements) -> Coverage {
|
pub fn compute_coverage(traced: &BTreeSet<String>, defined: &DefinedRequirements) -> Coverage {
|
||||||
let traced_reqs: BTreeSet<&String> = traced.iter().filter(|id| is_requirement(id)).collect();
|
let traced_reqs: BTreeSet<&String> = traced.iter().filter(|id| is_requirement(id)).collect();
|
||||||
|
|
||||||
@@ -309,6 +348,25 @@ pub fn compute_coverage(traced: &BTreeSet<String>, defined: &DefinedRequirements
|
|||||||
/// implementing it is generated into `OUT_DIR`, which is not scanned and could
|
/// implementing it is generated into `OUT_DIR`, which is not scanned and could
|
||||||
/// not be linked to from the report if it were. Without this a node would have
|
/// not be linked to from the report if it were. Without this a node would have
|
||||||
/// nowhere to record the requirement it satisfies.
|
/// nowhere to record the requirement it satisfies.
|
||||||
|
///
|
||||||
|
/// # What is deliberately absent, and why widening this is the wrong fix
|
||||||
|
///
|
||||||
|
/// `AndroidManifest.xml`, the Flatpak manifest, the Dockerfile and the CI
|
||||||
|
/// workflows all carry requirements — SAF grants, sandbox permissions, the API
|
||||||
|
/// levels NFR-COMPAT-1 names — and none of them can hold a tag. That reads like
|
||||||
|
/// a gap in this list. It is not one.
|
||||||
|
///
|
||||||
|
/// A tag proves that a tag exists, not that the file under it does the thing,
|
||||||
|
/// and a declarative manifest is the case where the difference bites hardest:
|
||||||
|
/// an intent filter can be deleted and the tag above it still says the
|
||||||
|
/// requirement is met. The convention already in the tree answers it better —
|
||||||
|
/// a Rust test `include_str!`s the file and asserts what must be in it, and
|
||||||
|
/// the tag goes on the test. That satisfies what CONTRIBUTING.md asks of any
|
||||||
|
/// tag, that it name something a test would fail without, which a comment in a
|
||||||
|
/// manifest never can.
|
||||||
|
///
|
||||||
|
/// So the list stays as it is, and a config file is tagged through the test
|
||||||
|
/// that reads it.
|
||||||
pub const SOURCE_SUFFIXES: &[&str] = &[".rs", ".slint", ".wgsl", ".yaml"];
|
pub const SOURCE_SUFFIXES: &[&str] = &[".rs", ".slint", ".wgsl", ".yaml"];
|
||||||
|
|
||||||
/// Directories never scanned.
|
/// Directories never scanned.
|
||||||
@@ -477,16 +535,23 @@ This is discussed in FR-CAT-1 and also FR-CAT-1 again.
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extracts_tags_with_both_separators() {
|
fn extracts_tags_with_both_separators() {
|
||||||
|
// **The ids here are UT and IT deliberately.** A multi-line literal is
|
||||||
|
// the one fixture shape `tag_body` cannot rule out: its lines begin
|
||||||
|
// with `///` in the file as well as in the string, so this really is a
|
||||||
|
// tag as far as any line-oriented reader can tell. Naming a
|
||||||
|
// requirement here would report it implemented by the traceability
|
||||||
|
// tool. UT and IT are excluded from coverage by `is_requirement`, so
|
||||||
|
// the fixture can be as tag-shaped as it likes and still cost nothing.
|
||||||
|
//
|
||||||
|
// The requirement id *shapes* are exercised by `darkroom_id_shapes_parse`
|
||||||
|
// below, which needs no `TRACES:` at all to do it.
|
||||||
let src = "\
|
let src = "\
|
||||||
/// TRACES: FR-CAT-1, FR-CAT-2 | NFR-P1
|
/// TRACES: UT-001, UT-002 | IT-003
|
||||||
pub fn scan() {}
|
pub fn scan() {}
|
||||||
";
|
";
|
||||||
let traces = extract_from_text(src, "x.rs");
|
let traces = extract_from_text(src, "x.rs");
|
||||||
assert_eq!(traces.len(), 1);
|
assert_eq!(traces.len(), 1);
|
||||||
assert_eq!(
|
assert_eq!(traces[0].requirements, vec!["UT-001", "UT-002", "IT-003"]);
|
||||||
traces[0].requirements,
|
|
||||||
vec!["FR-CAT-1", "FR-CAT-2", "NFR-P1"]
|
|
||||||
);
|
|
||||||
assert_eq!(traces[0].line, 1);
|
assert_eq!(traces[0].line, 1);
|
||||||
assert_eq!(traces[0].context, "pub fn scan()");
|
assert_eq!(traces[0].context, "pub fn scan()");
|
||||||
}
|
}
|
||||||
@@ -502,6 +567,71 @@ pub fn scan() {}
|
|||||||
assert_eq!(back[0].context, "pub fn render()");
|
assert_eq!(back[0].context, "pub fn render()");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_quoted_tag_is_not_a_tag() {
|
||||||
|
// What made the tool report `R1` as implemented: a fixture in this very
|
||||||
|
// module, scanned along with everything else, because a line mentioning
|
||||||
|
// `TRACES:` was taken for a line carrying it.
|
||||||
|
let quoted = extract_from_text(
|
||||||
|
r#"let s = "// TRACES: FR-CAT-1"; // and a real one below"#,
|
||||||
|
"x.rs",
|
||||||
|
);
|
||||||
|
assert!(quoted.is_empty(), "a tag inside an expression is not a tag");
|
||||||
|
|
||||||
|
// Emitted into generated code, which is the `build.rs` case.
|
||||||
|
let emitted = extract_from_text(r#" "/// TRACES: FR-DEV-3a\n","#, "x.rs");
|
||||||
|
assert!(emitted.is_empty(), "a tag being written out is not a tag");
|
||||||
|
|
||||||
|
// Prose about the mechanism, which is what this crate's own doc
|
||||||
|
// comments are full of.
|
||||||
|
let prose = extract_from_text("/// Extract every `TRACES:` tag. FR-CAT-1", "x.rs");
|
||||||
|
assert!(prose.is_empty(), "a tag named in prose is not a tag");
|
||||||
|
|
||||||
|
// And the forms that must keep working: SQL inside a Rust literal,
|
||||||
|
// which is how `schema.rs` tags its migrations, and YAML.
|
||||||
|
let sql = extract_from_text(" -- TRACES: FR-CULL-8\n", "x.rs");
|
||||||
|
assert_eq!(sql[0].requirements, vec!["FR-CULL-8"]);
|
||||||
|
let yaml = extract_from_text("# TRACES: FR-DEV-3a\nid: exposure\n", "x.yaml");
|
||||||
|
assert_eq!(yaml[0].requirements, vec!["FR-DEV-3a"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn this_crates_own_fixtures_cannot_reach_the_register() {
|
||||||
|
// `SOURCE_ROOTS` includes `tools`, so this crate is scanned by the tool
|
||||||
|
// it implements and a fixture below is indistinguishable from a tag on
|
||||||
|
// the code above. `tag_body` closes every shape but one — a multi-line
|
||||||
|
// literal whose lines begin with a comment opener — and this closes
|
||||||
|
// that one, by asking where the tag is rather than what it looks like.
|
||||||
|
//
|
||||||
|
// Tagging the tool's real code is still allowed; two such tags were
|
||||||
|
// wrong on other grounds and were removed, but the rule here is only
|
||||||
|
// that a fixture may not name a requirement. Use a `UT-` or `IT-` id.
|
||||||
|
for (name, src) in [
|
||||||
|
("lib.rs", include_str!("lib.rs")),
|
||||||
|
("gestures.rs", include_str!("gestures.rs")),
|
||||||
|
("main.rs", include_str!("main.rs")),
|
||||||
|
] {
|
||||||
|
let fixtures_begin = src
|
||||||
|
.lines()
|
||||||
|
.position(|l| l.trim_start().starts_with("mod tests"))
|
||||||
|
.map_or(usize::MAX, |i| i + 1);
|
||||||
|
|
||||||
|
for entry in extract_from_text(src, name) {
|
||||||
|
let reqs: Vec<&String> = entry
|
||||||
|
.requirements
|
||||||
|
.iter()
|
||||||
|
.filter(|id| is_requirement(id))
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
entry.line < fixtures_begin || reqs.is_empty(),
|
||||||
|
"{name}:{} is a test fixture naming {reqs:?}, which the \
|
||||||
|
register would read as implemented. Use a UT- or IT- id.",
|
||||||
|
entry.line,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_tag_with_no_ids_is_ignored() {
|
fn a_tag_with_no_ids_is_ignored() {
|
||||||
let traces = extract_from_text("// TRACES: see the design doc\n", "x.rs");
|
let traces = extract_from_text("// TRACES: see the design doc\n", "x.rs");
|
||||||
|
|||||||
+81
-1
@@ -12,6 +12,48 @@
|
|||||||
//! `OUT_DIR` as an include path makes the generated file answer every
|
//! `OUT_DIR` as an include path makes the generated file answer every
|
||||||
//! existing `import { Theme } from "theme.slint"` unchanged. This only works
|
//! existing `import { Theme } from "theme.slint"` unchanged. This only works
|
||||||
//! while no `ui/theme.slint` exists to shadow it — see the guard below.
|
//! while no `ui/theme.slint` exists to shadow it — see the guard below.
|
||||||
|
//!
|
||||||
|
//! # Translations (NFR-A11Y-1)
|
||||||
|
//!
|
||||||
|
//! A string written `@tr("Sign in")` in the markup is **already correct in a
|
||||||
|
//! build with no translation at all**: with neither the `gettext` nor the
|
||||||
|
//! `bundle-translations` path active, Slint's `translate()` formats the
|
||||||
|
//! original and returns it. That is the property that makes converting the
|
||||||
|
//! interface a string at a time possible rather than a flag day — the
|
||||||
|
//! alternative, wiring the machinery first and converting after, means every
|
||||||
|
//! intermediate commit ships an interface half of which cannot be translated
|
||||||
|
//! and none of which can be extracted.
|
||||||
|
//!
|
||||||
|
//! **Extracting.** `slint-tr-extractor` walks the `.slint` files and writes a
|
||||||
|
//! `.pot`:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! cargo install slint-tr-extractor
|
||||||
|
//! find ui/dr-ui/ui -name '*.slint' | xargs slint-tr-extractor -o dr-ui.pot
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Do **not** pass `--no-default-translation-context`. Slint's default
|
||||||
|
//! context is the enclosing component's name, which is what keeps the two
|
||||||
|
//! senses of a word like "or" apart when the same word is a conjunction on one
|
||||||
|
//! screen and a search operator on another — and the extractor and the
|
||||||
|
//! compiler have to agree about it or every lookup misses silently.
|
||||||
|
//!
|
||||||
|
//! **Delivering.** Two mechanisms exist and this one picks bundling: a `.po`
|
||||||
|
//! per language under `lang/<lang>/LC_MESSAGES/dr-ui.po`, compiled into the
|
||||||
|
//! binary by the block in `main`. The alternative is Slint's `gettext`
|
||||||
|
//! feature, which reads `.mo` files off disk at runtime through the C gettext
|
||||||
|
//! library. Bundling wins here for one reason that outranks the rest:
|
||||||
|
//! **Android**, where there is no filesystem path a `.mo` could sit at that
|
||||||
|
//! the app can reach under ARCH §6.9's storage model, and no C library to
|
||||||
|
//! link. A build with no `lang/` directory takes neither path and keeps every
|
||||||
|
//! original string, which is exactly the state of this crate today.
|
||||||
|
//!
|
||||||
|
//! **What this does not reach.** `@tr()` is markup. The operation and
|
||||||
|
//! parameter labels NFR-A11Y-1 names explicitly are resolved in Rust, by
|
||||||
|
//! `labels.rs`, from the `LocalizedKey`s the core publishes — the core cannot
|
||||||
|
//! depend on a localisation library (ARCH §6.5a), which is the constraint that
|
||||||
|
//! put the catalogue in the UI crate in the first place. Translating those
|
||||||
|
//! needs a second mechanism on the Rust side, and it is not built.
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
@@ -21,6 +63,8 @@ use serde_norway::Value;
|
|||||||
|
|
||||||
const STYLE_YAML: &str = "style.yaml";
|
const STYLE_YAML: &str = "style.yaml";
|
||||||
const GENERATED: &str = "theme.slint";
|
const GENERATED: &str = "theme.slint";
|
||||||
|
/// Where a `.po` goes, relative to this crate: `lang/<lang>/LC_MESSAGES/`.
|
||||||
|
const LANG_DIR: &str = "lang";
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
println!("cargo:rerun-if-changed={STYLE_YAML}");
|
println!("cargo:rerun-if-changed={STYLE_YAML}");
|
||||||
@@ -72,12 +116,48 @@ fn main() {
|
|||||||
// The failure is silent either way: a missing image loads as empty.
|
// The failure is silent either way: a missing image loads as empty.
|
||||||
// Embedding costs the size of ui/app-icon.png, the only asset this
|
// Embedding costs the size of ui/app-icon.png, the only asset this
|
||||||
// reaches, since every UI glyph is a Path rather than a file.
|
// reaches, since every UI glyph is a Path rather than a file.
|
||||||
let config = slint_build::CompilerConfiguration::new()
|
let mut config = slint_build::CompilerConfiguration::new()
|
||||||
.with_include_paths(vec![out_dir.clone(), manifest_dir.join("ui")])
|
.with_include_paths(vec![out_dir.clone(), manifest_dir.join("ui")])
|
||||||
.embed_resources(slint_build::EmbedResourcesKind::EmbedFiles);
|
.embed_resources(slint_build::EmbedResourcesKind::EmbedFiles);
|
||||||
|
|
||||||
|
// Bundling is asked for only once a translation exists to bundle.
|
||||||
|
//
|
||||||
|
// Enabling it unconditionally would make an empty `lang/` — or a
|
||||||
|
// missing one, which is every checkout today — into a build failure for
|
||||||
|
// everyone, in service of a feature nobody is yet using. Asking the
|
||||||
|
// directory instead means the mechanism is wired and inert: the first
|
||||||
|
// `lang/fr/LC_MESSAGES/dr-ui.po` someone commits turns it on with no
|
||||||
|
// build-system change, which is the point at which a translator can
|
||||||
|
// actually verify their work.
|
||||||
|
if let Some(lang) = translations(&manifest_dir) {
|
||||||
|
println!("cargo:rerun-if-changed={}", lang.display());
|
||||||
|
config = config.with_bundled_translations(lang);
|
||||||
|
}
|
||||||
|
|
||||||
slint_build::compile_with_config(entry(&out_dir, live), config).expect("compiling app.slint");
|
slint_build::compile_with_config(entry(&out_dir, live), config).expect("compiling app.slint");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The translation root, if any language has a catalogue in it.
|
||||||
|
///
|
||||||
|
/// The domain is the crate name — slint-build takes it from `CARGO_PKG_NAME`
|
||||||
|
/// and this reads the same variable, so a rename does not leave the two
|
||||||
|
/// halves looking for different files. Checking for a `.po` rather than
|
||||||
|
/// merely for the directory is deliberate: an empty `lang/` left behind by a
|
||||||
|
/// half-finished translation would otherwise switch bundling on and hand every
|
||||||
|
/// string to a lookup with nothing behind it.
|
||||||
|
fn translations(manifest_dir: &Path) -> Option<PathBuf> {
|
||||||
|
let root = manifest_dir.join(LANG_DIR);
|
||||||
|
let catalogue = format!("{}.po", env!("CARGO_PKG_NAME"));
|
||||||
|
let entries = std::fs::read_dir(&root).ok()?;
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
if entry.path().join("LC_MESSAGES").join(&catalogue).is_file() {
|
||||||
|
return Some(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// The file handed to the Slint compiler.
|
/// The file handed to the Slint compiler.
|
||||||
///
|
///
|
||||||
/// Normally `ui/app.slint` itself. Under `live-style` it is a generated
|
/// Normally `ui/app.slint` itself. Under `live-style` it is a generated
|
||||||
|
|||||||
+309
-151
@@ -50,40 +50,37 @@ use crate::{AppWindow, CollectionRow};
|
|||||||
struct PressUndo {
|
struct PressUndo {
|
||||||
selection: BTreeSet<ImageId>,
|
selection: BTreeSet<ImageId>,
|
||||||
anchor: Option<usize>,
|
anchor: Option<usize>,
|
||||||
previous_anchor: Option<usize>,
|
|
||||||
cursor: Option<usize>,
|
cursor: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PressUndo {
|
impl PressUndo {
|
||||||
/// Everything [`press_remembering_anchor`] is about to change.
|
/// Everything [`select_row`] is about to change.
|
||||||
///
|
///
|
||||||
/// The four are the whole of what a press touches, which is the property
|
/// The three are the whole of what a press touches, which is the property
|
||||||
/// the restore depends on and the reason it is worth a test of its own: an
|
/// the restore depends on and the reason it is worth a test of its own: a
|
||||||
/// press that grew a fifth piece of state would leave that piece behind
|
/// press that grew a fourth piece of state would leave that piece behind
|
||||||
/// after a cancel, silently.
|
/// after a cancel, silently.
|
||||||
fn capture(
|
fn capture(
|
||||||
selection: &BTreeSet<ImageId>,
|
selection: &BTreeSet<ImageId>,
|
||||||
anchor: Option<usize>,
|
anchor: Option<usize>,
|
||||||
previous_anchor: Option<usize>,
|
|
||||||
cursor: Option<usize>,
|
cursor: Option<usize>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
selection: selection.clone(),
|
selection: selection.clone(),
|
||||||
anchor,
|
anchor,
|
||||||
previous_anchor,
|
|
||||||
cursor,
|
cursor,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Put it all back. Returns the two the caller holds in `Cell`s.
|
/// Put it all back. Returns the one the caller holds in a `Cell`.
|
||||||
fn restore(
|
fn restore(
|
||||||
self,
|
self,
|
||||||
selection: &mut BTreeSet<ImageId>,
|
selection: &mut BTreeSet<ImageId>,
|
||||||
anchor: &mut Option<usize>,
|
anchor: &mut Option<usize>,
|
||||||
) -> (Option<usize>, Option<usize>) {
|
) -> Option<usize> {
|
||||||
*selection = self.selection;
|
*selection = self.selection;
|
||||||
*anchor = self.anchor;
|
*anchor = self.anchor;
|
||||||
(self.previous_anchor, self.cursor)
|
self.cursor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,18 +114,15 @@ pub struct CollectionsController {
|
|||||||
/// same argument applied to the one index that has to survive a move.
|
/// same argument applied to the one index that has to survive a move.
|
||||||
anchor: RefCell<Option<usize>>,
|
anchor: RefCell<Option<usize>>,
|
||||||
/// TRACES: FR-UI-2 | FR-UI-4
|
/// TRACES: FR-UI-2 | FR-UI-4
|
||||||
/// Where the anchor was *before* the press that moved it.
|
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||||
|
/// A photograph the most recent press asked to take *out* of the
|
||||||
|
/// selection, held until the release says the press was a tap.
|
||||||
///
|
///
|
||||||
/// The touch equivalent of shift-click needs this. A double tap is two
|
/// See [`Press::Deferred`] for why removal cannot happen on the press:
|
||||||
/// presses, and both of them move the anchor onto the cell being tapped —
|
/// this is the whole of what stops a drag of forty photographs carrying
|
||||||
/// so by the time the double tap is reported, "the range from the anchor to
|
/// one. Overwritten by the next press and dropped by the drag that
|
||||||
/// here" describes a single cell. This remembers the cell the user actually
|
/// consumes it, so at most one is ever pending.
|
||||||
/// started from, which is the one they mean.
|
pending_toggle: std::cell::Cell<Option<ImageId>>,
|
||||||
///
|
|
||||||
/// Updated only when a press *moves* the anchor, so the second tap of a
|
|
||||||
/// double tap — which lands on the cell that is already the anchor — leaves
|
|
||||||
/// it pointing where the first tap left it.
|
|
||||||
previous_anchor: std::cell::Cell<Option<usize>>,
|
|
||||||
/// TRACES: FR-UI-4
|
/// TRACES: FR-UI-4
|
||||||
/// What the selection was immediately before the most recent press, so a
|
/// What the selection was immediately before the most recent press, so a
|
||||||
/// gesture that turns out not to have been a press can put it back.
|
/// gesture that turns out not to have been a press can put it back.
|
||||||
@@ -368,22 +362,57 @@ impl CollectionsController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||||
|
/// What a press did, and what is left for the release to do.
|
||||||
|
///
|
||||||
|
/// Only one press has anything left over, and it is the one every multi-image
|
||||||
|
/// drag depends on — see [`Press::Deferred`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[must_use]
|
||||||
|
pub enum Press {
|
||||||
|
/// The selection is already what this press means. Nothing to finish.
|
||||||
|
Applied,
|
||||||
|
/// **Taking a photograph out of the selection waits for the release.**
|
||||||
|
///
|
||||||
|
/// Putting one *in* has to happen on the press: the drag that may follow
|
||||||
|
/// reads the selection to decide what it carries, and by the time the
|
||||||
|
/// finger lifts it is over the sidebar. Taking one out is the opposite —
|
||||||
|
/// nothing between the press and the release needs the image gone, and one
|
||||||
|
/// thing very much needs it to stay.
|
||||||
|
///
|
||||||
|
/// A plain press has said so since selection was written: pressing an
|
||||||
|
/// already-selected cell leaves the selection alone. Ctrl did not, and on a
|
||||||
|
/// tablet *every* press is a ctrl-press — that is what selection mode is.
|
||||||
|
/// So grabbing one of forty selected photographs deselected it on the way
|
||||||
|
/// down; the drag that followed found the cell under the finger no longer
|
||||||
|
/// in the selection, took that to mean an unselected image was being
|
||||||
|
/// dragged, and carried it alone. Forty photographs became one, and the
|
||||||
|
/// only clue was the grabbed cell's ring blinking out.
|
||||||
|
///
|
||||||
|
/// So the removal is handed back for the *click* to apply, and a click
|
||||||
|
/// fires only for a press that stayed put — never for one that became a
|
||||||
|
/// drag. See `tap-slop` in `library.slint`.
|
||||||
|
Deferred(ImageId),
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply a press to the selection.
|
/// Apply a press to the selection.
|
||||||
///
|
///
|
||||||
/// Split from the callback so the policy is testable without a window: this is
|
/// Split from the callback so the policy is testable without a window: this is
|
||||||
/// the part a user notices being wrong.
|
/// the part a user notices being wrong.
|
||||||
///
|
///
|
||||||
/// - **plain** — replace the selection with this one image
|
/// - **plain** — replace the selection with this one image
|
||||||
/// - **ctrl** — toggle this image, keeping the rest, and move the anchor here
|
/// - **ctrl** — add this image to the selection, keeping the rest, and move the
|
||||||
|
/// anchor here; taking one *out* is [deferred](Press::Deferred) to the release
|
||||||
/// - **shift** — select the range from the anchor to here, *replacing* what was
|
/// - **shift** — select the range from the anchor to here, *replacing* what was
|
||||||
/// selected; the anchor stays put, so an overshoot is corrected by
|
/// selected; the anchor stays put, so an overshoot is corrected by
|
||||||
/// shift-clicking the right cell rather than starting again
|
/// shift-clicking the right cell rather than starting again
|
||||||
/// - **ctrl+shift** — the same range, *added* to the selection, for picking up a
|
/// - **ctrl+shift** — the same range, *added* to the selection, for picking up a
|
||||||
/// second run without losing the first
|
/// second run without losing the first
|
||||||
///
|
///
|
||||||
/// A plain press on an image that is *already* selected leaves the selection
|
/// A press on an image that is *already* selected never removes it here —
|
||||||
/// alone. That is what makes dragging a multi-selection possible at all — the
|
/// plainly or with ctrl. That is what makes dragging a multi-selection possible
|
||||||
/// press that begins the drag would otherwise collapse the selection to one.
|
/// at all: the press that begins the drag would otherwise take the grabbed
|
||||||
|
/// photograph out from under it.
|
||||||
///
|
///
|
||||||
/// `ids` is the **loaded window**, `offset` where it starts in the library and
|
/// `ids` is the **loaded window**, `offset` where it starts in the library and
|
||||||
/// `row` a position within it; the anchor is kept as `offset + row`, an
|
/// `row` a position within it; the anchor is kept as `offset + row`, an
|
||||||
@@ -402,8 +431,10 @@ pub fn apply_press(
|
|||||||
ctrl: bool,
|
ctrl: bool,
|
||||||
shift: bool,
|
shift: bool,
|
||||||
span: &dyn Fn(usize, usize) -> Vec<ImageId>,
|
span: &dyn Fn(usize, usize) -> Vec<ImageId>,
|
||||||
) {
|
) -> Press {
|
||||||
let Some(&id) = ids.get(row) else { return };
|
let Some(&id) = ids.get(row) else {
|
||||||
|
return Press::Applied;
|
||||||
|
};
|
||||||
let here = offset + row;
|
let here = offset + row;
|
||||||
|
|
||||||
if shift {
|
if shift {
|
||||||
@@ -413,7 +444,7 @@ pub fn apply_press(
|
|||||||
selection.clear();
|
selection.clear();
|
||||||
selection.insert(id);
|
selection.insert(id);
|
||||||
*anchor = Some(here);
|
*anchor = Some(here);
|
||||||
return;
|
return Press::Applied;
|
||||||
};
|
};
|
||||||
|
|
||||||
// The anchor deliberately does **not** move. Shift-clicking again
|
// The anchor deliberately does **not** move. Shift-clicking again
|
||||||
@@ -459,15 +490,19 @@ pub fn apply_press(
|
|||||||
run = ids[lo_row..=hi_row].to_vec();
|
run = ids[lo_row..=hi_row].to_vec();
|
||||||
}
|
}
|
||||||
selection.extend(run);
|
selection.extend(run);
|
||||||
return;
|
return Press::Applied;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ctrl {
|
if ctrl {
|
||||||
if !selection.remove(&id) {
|
|
||||||
selection.insert(id);
|
|
||||||
}
|
|
||||||
*anchor = Some(here);
|
*anchor = Some(here);
|
||||||
return;
|
|
||||||
|
// Taking one out waits for the release — see [`Press::Deferred`].
|
||||||
|
if selection.contains(&id) {
|
||||||
|
return Press::Deferred(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
selection.insert(id);
|
||||||
|
return Press::Applied;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plain press on something already selected: leave it. The drag that may
|
// Plain press on something already selected: leave it. The drag that may
|
||||||
@@ -475,41 +510,13 @@ pub fn apply_press(
|
|||||||
// multi-image drag impossible to start.
|
// multi-image drag impossible to start.
|
||||||
if selection.contains(&id) {
|
if selection.contains(&id) {
|
||||||
*anchor = Some(here);
|
*anchor = Some(here);
|
||||||
return;
|
return Press::Applied;
|
||||||
}
|
}
|
||||||
|
|
||||||
selection.clear();
|
selection.clear();
|
||||||
selection.insert(id);
|
selection.insert(id);
|
||||||
*anchor = Some(here);
|
*anchor = Some(here);
|
||||||
}
|
Press::Applied
|
||||||
|
|
||||||
/// TRACES: FR-UI-2 | FR-UI-4
|
|
||||||
/// Apply a press, and remember where the anchor was before it moved.
|
|
||||||
///
|
|
||||||
/// The bookkeeping a double tap depends on, split out from the callback so the
|
|
||||||
/// touch sequence — hold one cell, double-tap another, get the run between —
|
|
||||||
/// can be tested without a window. See [`CollectionsController::previous_anchor`]
|
|
||||||
/// for why the *previous* anchor is the one a double tap means.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn press_remembering_anchor(
|
|
||||||
selection: &mut BTreeSet<ImageId>,
|
|
||||||
anchor: &mut Option<usize>,
|
|
||||||
previous: &mut Option<usize>,
|
|
||||||
ids: &[ImageId],
|
|
||||||
offset: usize,
|
|
||||||
row: usize,
|
|
||||||
ctrl: bool,
|
|
||||||
shift: bool,
|
|
||||||
span: &dyn Fn(usize, usize) -> Vec<ImageId>,
|
|
||||||
) {
|
|
||||||
let before = *anchor;
|
|
||||||
apply_press(selection, anchor, ids, offset, row, ctrl, shift, span);
|
|
||||||
// Only a press that *moved* the anchor updates this. The second tap of a
|
|
||||||
// double tap lands on the cell the first tap made the anchor, so it changes
|
|
||||||
// nothing and the origin survives to be extended from.
|
|
||||||
if *anchor != before {
|
|
||||||
*previous = before;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a press and push the result into the grid — the whole of what a
|
/// Apply a press and push the result into the grid — the whole of what a
|
||||||
@@ -533,23 +540,25 @@ pub fn select_row(
|
|||||||
*ctl.press_undo.borrow_mut() = Some(PressUndo::capture(
|
*ctl.press_undo.borrow_mut() = Some(PressUndo::capture(
|
||||||
&ctl.selection.borrow(),
|
&ctl.selection.borrow(),
|
||||||
*ctl.anchor.borrow(),
|
*ctl.anchor.borrow(),
|
||||||
ctl.previous_anchor.get(),
|
|
||||||
ctl.cursor(),
|
ctl.cursor(),
|
||||||
));
|
));
|
||||||
|
|
||||||
let mut previous = ctl.previous_anchor.get();
|
// Whatever the last press left pending is answered by this one: two
|
||||||
press_remembering_anchor(
|
// presses without a click in between means the first never was a tap.
|
||||||
|
ctl.pending_toggle.set(None);
|
||||||
|
|
||||||
|
if let Press::Deferred(id) = apply_press(
|
||||||
&mut ctl.selection.borrow_mut(),
|
&mut ctl.selection.borrow_mut(),
|
||||||
&mut ctl.anchor.borrow_mut(),
|
&mut ctl.anchor.borrow_mut(),
|
||||||
&mut previous,
|
|
||||||
ids,
|
ids,
|
||||||
offset,
|
offset,
|
||||||
row,
|
row,
|
||||||
ctrl,
|
ctrl,
|
||||||
shift,
|
shift,
|
||||||
&|first, last| ctl.span(first, last),
|
&|first, last| ctl.span(first, last),
|
||||||
);
|
) {
|
||||||
ctl.previous_anchor.set(previous);
|
ctl.pending_toggle.set(Some(id));
|
||||||
|
}
|
||||||
// The cursor follows the press, so an arrow key after a click continues
|
// The cursor follows the press, so an arrow key after a click continues
|
||||||
// from the cell that was clicked rather than from wherever the keyboard
|
// from the cell that was clicked rather than from wherever the keyboard
|
||||||
// was last.
|
// was last.
|
||||||
@@ -557,6 +566,26 @@ pub fn select_row(
|
|||||||
sync_selection(window, ctl, ids);
|
sync_selection(window, ctl, ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||||
|
/// Finish a press that turned out to be a tap.
|
||||||
|
///
|
||||||
|
/// The other half of [`Press::Deferred`]: a ctrl-press on an already-selected
|
||||||
|
/// photograph leaves it in, because the drag that may follow has to be able to
|
||||||
|
/// carry it, and this takes it out again once the release has proved there was
|
||||||
|
/// no drag.
|
||||||
|
///
|
||||||
|
/// Called from the click, which Slint reports only for a press that stayed
|
||||||
|
/// within `tap-slop` of where it landed — so a press that became a drag never
|
||||||
|
/// reaches here, and a press taken over by the Flickable never reaches here
|
||||||
|
/// either. Both leave the pending removal to be dropped by the next press.
|
||||||
|
pub fn commit_press(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[ImageId]) {
|
||||||
|
let Some(id) = ctl.pending_toggle.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
ctl.selection.borrow_mut().remove(&id);
|
||||||
|
sync_selection(window, ctl, ids);
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-UI-4
|
/// TRACES: FR-UI-4
|
||||||
/// Put the selection back as it was before the most recent press.
|
/// Put the selection back as it was before the most recent press.
|
||||||
///
|
///
|
||||||
@@ -568,14 +597,22 @@ pub fn select_row(
|
|||||||
/// Idempotent, and a no-op when there is nothing to undo, so it is safe to
|
/// Idempotent, and a no-op when there is nothing to undo, so it is safe to
|
||||||
/// call on every gesture start rather than only on the ones that need it.
|
/// call on every gesture start rather than only on the ones that need it.
|
||||||
pub fn cancel_press(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[ImageId]) {
|
pub fn cancel_press(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[ImageId]) {
|
||||||
|
// The press is being unmade, so what it left for the release to finish is
|
||||||
|
// unmade with it. Cleared before the early return: a press that changed
|
||||||
|
// nothing to undo can still have deferred a removal — and a finger that
|
||||||
|
// held long enough to pick a photograph up before the second one landed
|
||||||
|
// has to put it back down, or the ring stays open around a cell nobody is
|
||||||
|
// touching and the grid stays frozen with it.
|
||||||
|
ctl.pending_toggle.set(None);
|
||||||
|
window.set_library_held_row(-1);
|
||||||
|
|
||||||
let Some(undo) = ctl.press_undo.borrow_mut().take() else {
|
let Some(undo) = ctl.press_undo.borrow_mut().take() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let (previous_anchor, cursor) = undo.restore(
|
let cursor = undo.restore(
|
||||||
&mut ctl.selection.borrow_mut(),
|
&mut ctl.selection.borrow_mut(),
|
||||||
&mut ctl.anchor.borrow_mut(),
|
&mut ctl.anchor.borrow_mut(),
|
||||||
);
|
);
|
||||||
ctl.previous_anchor.set(previous_anchor);
|
|
||||||
ctl.set_cursor(cursor);
|
ctl.set_cursor(cursor);
|
||||||
sync_selection(window, ctl, ids);
|
sync_selection(window, ctl, ids);
|
||||||
}
|
}
|
||||||
@@ -1022,7 +1059,7 @@ pub(crate) const HOLD_DELAY_MS: u64 = 450;
|
|||||||
/// press that armed it has *already* selected the cell under the finger, so
|
/// press that armed it has *already* selected the cell under the finger, so
|
||||||
/// what firing adds is the mode: from here taps toggle rather than open, and
|
/// what firing adds is the mode: from here taps toggle rather than open, and
|
||||||
/// the header's buttons appear to act on what has been gathered.
|
/// the header's buttons appear to act on what has been gathered.
|
||||||
fn arm_hold(window: &AppWindow, ctl: &Rc<CollectionsController>) {
|
fn arm_hold(window: &AppWindow, ctl: &Rc<CollectionsController>, row: i32) {
|
||||||
let timer = slint::Timer::default();
|
let timer = slint::Timer::default();
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
let ctl_cb = ctl.clone();
|
let ctl_cb = ctl.clone();
|
||||||
@@ -1032,8 +1069,22 @@ fn arm_hold(window: &AppWindow, ctl: &Rc<CollectionsController>) {
|
|||||||
std::time::Duration::from_millis(HOLD_DELAY_MS),
|
std::time::Duration::from_millis(HOLD_DELAY_MS),
|
||||||
move || {
|
move || {
|
||||||
let Some(w) = weak.upgrade() else { return };
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
|
||||||
|
// TRACES: FR-CAT-7
|
||||||
|
// The photograph is in the user's hand: the grid draws a ring
|
||||||
|
// opening around it and stops scrolling underneath it, so the drag
|
||||||
|
// that may follow cannot be lost to a flick. See `held-row` in
|
||||||
|
// `library.slint`.
|
||||||
|
//
|
||||||
|
// This half happens whether or not selection mode was already on —
|
||||||
|
// it is the half a *drag* needs, and a drag out of a selection of
|
||||||
|
// forty starts in a mode that is already on.
|
||||||
|
w.set_library_held_row(row);
|
||||||
|
|
||||||
|
if !ctl_cb.select_mode.get() {
|
||||||
ctl_cb.select_mode.set(true);
|
ctl_cb.select_mode.set(true);
|
||||||
w.set_library_select_mode(true);
|
w.set_library_select_mode(true);
|
||||||
|
}
|
||||||
|
|
||||||
// The release that follows this hold must not also open the image:
|
// The release that follows this hold must not also open the image:
|
||||||
// the user asked for a selection and would land in develop instead.
|
// the user asked for a selection and would land in develop instead.
|
||||||
@@ -1498,17 +1549,20 @@ pub fn wire<S, R, P, C>(
|
|||||||
let offset = w.get_library_offset().max(0) as usize;
|
let offset = w.get_library_offset().max(0) as usize;
|
||||||
select_row(&w, &ctl, &ids, offset, row as usize, ctrl_held, shift_held);
|
select_row(&w, &ctl, &ids, offset, row as usize, ctrl_held, shift_held);
|
||||||
|
|
||||||
// TRACES: FR-UI-2 | FR-UI-4
|
// TRACES: FR-UI-2 | FR-UI-4 | FR-CAT-7
|
||||||
// And start counting, in case this press is a hold. The press has
|
// And start counting, in case this press is a hold. The press has
|
||||||
// already selected this one cell; what the hold adds is the *mode*,
|
// already selected this one cell; what the hold adds is the *mode*,
|
||||||
// so the taps that follow go on selecting instead of opening the
|
// so the taps that follow go on selecting instead of opening the
|
||||||
// next photograph the user touches.
|
// next photograph the user touches — and the *pick-up*, which is
|
||||||
|
// what makes the drag reliable.
|
||||||
//
|
//
|
||||||
// Not started when the mode is already on: it is on, and a second
|
// Armed even when the mode is already on, which it did not used to
|
||||||
// hold would have nothing to do but suppress the tap that ends it.
|
// be: there was nothing left for the hold to switch on, so it was
|
||||||
if !ctl.select_mode.get() {
|
// skipped. But the mode being on is exactly the state a
|
||||||
arm_hold(&w, &ctl);
|
// multi-image drag starts from, and skipping the hold left that
|
||||||
}
|
// drag with no pick-up and no cue — the one gesture that most
|
||||||
|
// needed both. See `arm_hold`.
|
||||||
|
arm_hold(&w, &ctl, row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1856,16 +1910,33 @@ pub fn wire<S, R, P, C>(
|
|||||||
// Slint owns the gesture (see the preamble). What is left here is the
|
// Slint owns the gesture (see the preamble). What is left here is the
|
||||||
// payload — the image ids the drop will act on — and the spring.
|
// payload — the image ids the drop will act on — and the spring.
|
||||||
|
|
||||||
// The payload is built when the drag starts, so it is the selection as it
|
// TRACES: FR-CAT-7
|
||||||
// stands at that moment rather than whatever it becomes mid-flight.
|
// **What arms the drag, not what it carries.**
|
||||||
|
//
|
||||||
|
// `DragArea` declines to start a drag while its `data` is empty, and it
|
||||||
|
// tests that on every pointer event that reaches it — the first one
|
||||||
|
// included, which arrives long before any drag. The binding in
|
||||||
|
// `library.slint` calls this callback, and a binding that calls a callback
|
||||||
|
// has nothing Slint can invalidate it on: it is evaluated once, when a
|
||||||
|
// finger first lands on that cell, and cached. `dragging` is empty at that
|
||||||
|
// moment and stays empty until `drag-started` fires.
|
||||||
|
//
|
||||||
|
// So this is answered at the wrong time, and always will be. That is
|
||||||
|
// harmless only because **`set_user_data` is called unconditionally**: an
|
||||||
|
// empty `Vec` is still user data, so the transfer is never `is_empty()` and
|
||||||
|
// the `DragArea` stays armed. Skipping the call for an empty selection —
|
||||||
|
// which looks like an obvious tidy-up — would disarm every cell a finger
|
||||||
|
// had ever touched outside a drag, and dragging would simply stop working
|
||||||
|
// with nothing to see.
|
||||||
|
//
|
||||||
|
// What the drop actually reads is `dragging`, set by `drag-started` and
|
||||||
|
// read back by `dropped-on`. `user_data` rather than plain text so that
|
||||||
|
// nothing outside the application can interpret it as a paste.
|
||||||
{
|
{
|
||||||
let ctl = ctl.clone();
|
let ctl = ctl.clone();
|
||||||
window.on_library_drag_payload(move || {
|
window.on_library_drag_payload(move || {
|
||||||
let carried = ctl.dragging.borrow().clone();
|
let carried = ctl.dragging.borrow().clone();
|
||||||
let mut data = slint::DataTransfer::default();
|
let mut data = slint::DataTransfer::default();
|
||||||
// `user_data` rather than plain text: these are catalog ids for our
|
|
||||||
// own drop handler, not something another application should be
|
|
||||||
// able to interpret as a paste.
|
|
||||||
data.set_user_data(Rc::new(carried));
|
data.set_user_data(Rc::new(carried));
|
||||||
data
|
data
|
||||||
});
|
});
|
||||||
@@ -1899,6 +1970,15 @@ pub fn wire<S, R, P, C>(
|
|||||||
// meant. The pinch already cancels for exactly this reason.
|
// meant. The pinch already cancels for exactly this reason.
|
||||||
*ctl.hold_timer.borrow_mut() = None;
|
*ctl.hold_timer.borrow_mut() = None;
|
||||||
|
|
||||||
|
// TRACES: FR-CAT-7
|
||||||
|
// And this press was not a tap, so it never gets to take anything
|
||||||
|
// out of the selection — see [`Press::Deferred`]. Dropped here as
|
||||||
|
// well as on the next press because the drag reads the selection
|
||||||
|
// one line below, and a removal still pending would be a
|
||||||
|
// photograph the user can see is selected and the drop would not
|
||||||
|
// carry.
|
||||||
|
ctl.pending_toggle.set(None);
|
||||||
|
|
||||||
// Dragging an *unselected* cell carries only that one, and makes it
|
// Dragging an *unselected* cell carries only that one, and makes it
|
||||||
// the selection — otherwise the images that travel are not the ones
|
// the selection — otherwise the images that travel are not the ones
|
||||||
// the user grabbed. Dragging a selected cell carries the whole
|
// the user grabbed. Dragging a selected cell carries the whole
|
||||||
@@ -2062,6 +2142,10 @@ pub fn wire<S, R, P, C>(
|
|||||||
let landed = ctl.dropped_on.borrow_mut().take();
|
let landed = ctl.dropped_on.borrow_mut().take();
|
||||||
let to_trash = ctl.trash_requested.borrow_mut().take();
|
let to_trash = ctl.trash_requested.borrow_mut().take();
|
||||||
ctl.dragging.borrow_mut().clear();
|
ctl.dragging.borrow_mut().clear();
|
||||||
|
// The grid clears this itself on the cancel that starts a drag;
|
||||||
|
// this is for the endings that reach no cell — a drop, or a drag
|
||||||
|
// abandoned over nothing.
|
||||||
|
w.set_library_held_row(-1);
|
||||||
*ctl.hover_id.borrow_mut() = None;
|
*ctl.hover_id.borrow_mut() = None;
|
||||||
*ctl.spring_timer.borrow_mut() = None;
|
*ctl.spring_timer.borrow_mut() = None;
|
||||||
|
|
||||||
@@ -2657,6 +2741,30 @@ fn unique_name(conn: &rusqlite::Connection, parent: Option<CollectionId>) -> Str
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
||||||
|
/// A press and the release that follows it — which is what a click is.
|
||||||
|
///
|
||||||
|
/// These tests are about what a *user* sees, and a user only ever presses
|
||||||
|
/// and lets go. Calling [`apply_press`] alone would drop the half of the
|
||||||
|
/// policy that waits for the release ([`Press::Deferred`]) and quietly
|
||||||
|
/// assert the wrong thing about ctrl.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn click(
|
||||||
|
selection: &mut BTreeSet<ImageId>,
|
||||||
|
anchor: &mut Option<usize>,
|
||||||
|
ids: &[ImageId],
|
||||||
|
offset: usize,
|
||||||
|
row: usize,
|
||||||
|
ctrl: bool,
|
||||||
|
shift: bool,
|
||||||
|
span: &dyn Fn(usize, usize) -> Vec<ImageId>,
|
||||||
|
) {
|
||||||
|
if let Press::Deferred(id) =
|
||||||
|
apply_press(selection, anchor, ids, offset, row, ctrl, shift, span)
|
||||||
|
{
|
||||||
|
selection.remove(&id);
|
||||||
|
}
|
||||||
|
}
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn ids(n: u64) -> Vec<ImageId> {
|
fn ids(n: u64) -> Vec<ImageId> {
|
||||||
@@ -2730,8 +2838,8 @@ mod tests {
|
|||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 2, false, false, &span);
|
click(&mut sel, &mut anchor, &all, 0, 2, false, false, &span);
|
||||||
|
|
||||||
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(3)]);
|
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(3)]);
|
||||||
}
|
}
|
||||||
@@ -2743,12 +2851,12 @@ mod tests {
|
|||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||||
assert_eq!(sel.len(), 2);
|
assert_eq!(sel.len(), 2);
|
||||||
|
|
||||||
// Toggling: a second ctrl-press on the same cell takes it out again.
|
// Toggling: a second ctrl-press on the same cell takes it out again.
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||||
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(1)]);
|
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(1)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2759,8 +2867,8 @@ mod tests {
|
|||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 2, false, false, &span);
|
click(&mut sel, &mut anchor, &all, 0, 2, false, false, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 6, false, true, &span);
|
click(&mut sel, &mut anchor, &all, 0, 6, false, true, &span);
|
||||||
|
|
||||||
assert_eq!(sel.len(), 5, "rows 2..=6 inclusive");
|
assert_eq!(sel.len(), 5, "rows 2..=6 inclusive");
|
||||||
assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(7)));
|
assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(7)));
|
||||||
@@ -2773,8 +2881,8 @@ mod tests {
|
|||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 6, false, false, &span);
|
click(&mut sel, &mut anchor, &all, 0, 6, false, false, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 2, false, true, &span);
|
click(&mut sel, &mut anchor, &all, 0, 2, false, true, &span);
|
||||||
assert_eq!(sel.len(), 5);
|
assert_eq!(sel.len(), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2788,12 +2896,12 @@ mod tests {
|
|||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 5, false, false, &span);
|
click(&mut sel, &mut anchor, &all, 0, 5, false, false, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 15, false, true, &span);
|
click(&mut sel, &mut anchor, &all, 0, 15, false, true, &span);
|
||||||
assert_eq!(sel.len(), 11, "rows 5..=15");
|
assert_eq!(sel.len(), 11, "rows 5..=15");
|
||||||
|
|
||||||
// Corrected to a shorter range from the same anchor.
|
// Corrected to a shorter range from the same anchor.
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 8, false, true, &span);
|
click(&mut sel, &mut anchor, &all, 0, 8, false, true, &span);
|
||||||
assert_eq!(sel.len(), 4, "rows 5..=8, and nothing from the first range");
|
assert_eq!(sel.len(), 4, "rows 5..=8, and nothing from the first range");
|
||||||
assert!(!sel.contains(&ImageId(16)), "row 15 is no longer selected");
|
assert!(!sel.contains(&ImageId(16)), "row 15 is no longer selected");
|
||||||
}
|
}
|
||||||
@@ -2807,9 +2915,9 @@ mod tests {
|
|||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 10, false, false, &span);
|
click(&mut sel, &mut anchor, &all, 0, 10, false, false, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 14, false, true, &span);
|
click(&mut sel, &mut anchor, &all, 0, 14, false, true, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 12, false, true, &span);
|
click(&mut sel, &mut anchor, &all, 0, 12, false, true, &span);
|
||||||
|
|
||||||
assert_eq!(anchor, Some(10));
|
assert_eq!(anchor, Some(10));
|
||||||
assert_eq!(sel.len(), 3, "rows 10..=12");
|
assert_eq!(sel.len(), 3, "rows 10..=12");
|
||||||
@@ -2830,25 +2938,14 @@ mod tests {
|
|||||||
let span = library(&all);
|
let span = library(&all);
|
||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
let mut previous = None;
|
|
||||||
|
|
||||||
// Selecting began somewhere else, so a range gesture would have had an
|
// Selecting began somewhere else, so a range gesture would have had an
|
||||||
// anchor to sweep from.
|
// anchor to sweep from.
|
||||||
press_remembering_anchor(
|
click(&mut sel, &mut anchor, &all, 0, 4, false, false, &span);
|
||||||
&mut sel,
|
|
||||||
&mut anchor,
|
|
||||||
&mut previous,
|
|
||||||
&all,
|
|
||||||
0,
|
|
||||||
4,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
&span,
|
|
||||||
);
|
|
||||||
assert_eq!(sel.len(), 1);
|
assert_eq!(sel.len(), 1);
|
||||||
|
|
||||||
tap(&mut sel, &mut anchor, &mut previous, &all, 11);
|
tap(&mut sel, &mut anchor, &all, 11);
|
||||||
tap(&mut sel, &mut anchor, &mut previous, &all, 11);
|
tap(&mut sel, &mut anchor, &all, 11);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
sel.iter().copied().collect::<Vec<_>>(),
|
sel.iter().copied().collect::<Vec<_>>(),
|
||||||
@@ -2859,24 +2956,86 @@ mod tests {
|
|||||||
|
|
||||||
/// One tap in selection mode: a press reported as ctrl-held, which is what
|
/// One tap in selection mode: a press reported as ctrl-held, which is what
|
||||||
/// `library.slint` sends while the mode is on.
|
/// `library.slint` sends while the mode is on.
|
||||||
fn tap(
|
fn tap(sel: &mut BTreeSet<ImageId>, anchor: &mut Option<usize>, all: &[ImageId], row: usize) {
|
||||||
sel: &mut BTreeSet<ImageId>,
|
click(sel, anchor, all, 0, row, true, false, &library(all));
|
||||||
anchor: &mut Option<usize>,
|
}
|
||||||
previous: &mut Option<usize>,
|
|
||||||
all: &[ImageId],
|
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||||
row: usize,
|
/// The bug that made a forty-image drag file one photograph.
|
||||||
) {
|
///
|
||||||
press_remembering_anchor(
|
/// In selection mode every press arrives as a ctrl-press, so grabbing one
|
||||||
sel,
|
/// of the selected cells to drag them all used to *deselect* the cell being
|
||||||
anchor,
|
/// grabbed. `drag-started` then saw a press on an image that was not in the
|
||||||
previous,
|
/// selection, concluded that was the gesture, and carried it alone.
|
||||||
all,
|
#[test]
|
||||||
0,
|
fn grabbing_a_selected_cell_leaves_the_whole_selection_to_drag() {
|
||||||
row,
|
let all = ids(20);
|
||||||
true,
|
let span = library(&all);
|
||||||
false,
|
let mut sel = BTreeSet::new();
|
||||||
&library(all),
|
let mut anchor = None;
|
||||||
|
|
||||||
|
// Three photographs picked in selection mode, which reports ctrl.
|
||||||
|
for row in [2, 5, 9] {
|
||||||
|
click(&mut sel, &mut anchor, &all, 0, row, true, false, &span);
|
||||||
|
}
|
||||||
|
assert_eq!(sel.len(), 3);
|
||||||
|
|
||||||
|
// The press that begins the drag, on one of the three.
|
||||||
|
let outcome = apply_press(&mut sel, &mut anchor, &all, 0, 5, true, false, &span);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
outcome,
|
||||||
|
Press::Deferred(ImageId(6)),
|
||||||
|
"the removal has to be handed back, not performed"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sel.len(),
|
||||||
|
3,
|
||||||
|
"the drag reads the selection next, and all three have to still be in it"
|
||||||
|
);
|
||||||
|
assert!(sel.contains(&ImageId(6)), "least of all the one being held");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// And the other half: with no drag, the release still takes it out.
|
||||||
|
///
|
||||||
|
/// A tap in selection mode has to toggle, or there is no way to correct a
|
||||||
|
/// mis-tap short of leaving the mode.
|
||||||
|
#[test]
|
||||||
|
fn a_tap_on_a_selected_cell_still_takes_it_out() {
|
||||||
|
let all = ids(20);
|
||||||
|
let span = library(&all);
|
||||||
|
let mut sel = BTreeSet::new();
|
||||||
|
let mut anchor = None;
|
||||||
|
|
||||||
|
for row in [2, 5, 9] {
|
||||||
|
click(&mut sel, &mut anchor, &all, 0, row, true, false, &span);
|
||||||
|
}
|
||||||
|
|
||||||
|
click(&mut sel, &mut anchor, &all, 0, 5, true, false, &span);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sel.iter().copied().collect::<Vec<_>>(),
|
||||||
|
vec![ImageId(3), ImageId(10)],
|
||||||
|
"the tapped photograph is out and the other two stayed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The anchor moves on the press whether or not the removal does.
|
||||||
|
///
|
||||||
|
/// "Select to…" measures from the anchor, and a run taken after tapping a
|
||||||
|
/// selected cell has to start where the user last touched — otherwise the
|
||||||
|
/// gesture sweeps from wherever the anchor happened to be left.
|
||||||
|
#[test]
|
||||||
|
fn a_deferred_press_still_moves_the_anchor() {
|
||||||
|
let all = ids(20);
|
||||||
|
let span = library(&all);
|
||||||
|
let mut sel = BTreeSet::new();
|
||||||
|
let mut anchor = None;
|
||||||
|
|
||||||
|
click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||||
|
let _ = apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||||
|
|
||||||
|
assert_eq!(anchor, Some(3));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2888,13 +3047,13 @@ mod tests {
|
|||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 2, false, true, &span);
|
click(&mut sel, &mut anchor, &all, 0, 2, false, true, &span);
|
||||||
assert_eq!(sel.len(), 3);
|
assert_eq!(sel.len(), 3);
|
||||||
|
|
||||||
// A new anchor by ctrl-click, then a ctrl+shift range from it.
|
// A new anchor by ctrl-click, then a ctrl+shift range from it.
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 10, true, false, &span);
|
click(&mut sel, &mut anchor, &all, 0, 10, true, false, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 12, true, true, &span);
|
click(&mut sel, &mut anchor, &all, 0, 12, true, true, &span);
|
||||||
|
|
||||||
assert_eq!(sel.len(), 6, "rows 0..=2 and 10..=12");
|
assert_eq!(sel.len(), 6, "rows 0..=2 and 10..=12");
|
||||||
assert!(sel.contains(&ImageId(1)) && sel.contains(&ImageId(13)));
|
assert!(sel.contains(&ImageId(1)) && sel.contains(&ImageId(13)));
|
||||||
@@ -2909,13 +3068,13 @@ mod tests {
|
|||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 1, true, false, &span);
|
click(&mut sel, &mut anchor, &all, 0, 1, true, false, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 2, true, false, &span);
|
click(&mut sel, &mut anchor, &all, 0, 2, true, false, &span);
|
||||||
assert_eq!(sel.len(), 3);
|
assert_eq!(sel.len(), 3);
|
||||||
|
|
||||||
// Pressing one of the three to begin a drag.
|
// Pressing one of the three to begin a drag.
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 1, false, false, &span);
|
click(&mut sel, &mut anchor, &all, 0, 1, false, false, &span);
|
||||||
assert_eq!(sel.len(), 3, "the selection survived the press");
|
assert_eq!(sel.len(), 3, "the selection survived the press");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2932,23 +3091,22 @@ mod tests {
|
|||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
// A selection built the ordinary way, and the state it leaves behind.
|
// A selection built the ordinary way, and the state it leaves behind.
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 1, false, false, &span);
|
click(&mut sel, &mut anchor, &all, 0, 1, false, false, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||||
let before = (sel.clone(), anchor);
|
let before = (sel.clone(), anchor);
|
||||||
|
|
||||||
// The finger that opens a pinch.
|
// The finger that opens a pinch.
|
||||||
let undo = PressUndo::capture(&sel, anchor, Some(1), Some(3));
|
let undo = PressUndo::capture(&sel, anchor, Some(3));
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 5, false, false, &span);
|
click(&mut sel, &mut anchor, &all, 0, 5, false, false, &span);
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
(sel.clone(), anchor),
|
(sel.clone(), anchor),
|
||||||
before,
|
before,
|
||||||
"the press has to change something, or this proves nothing"
|
"the press has to change something, or this proves nothing"
|
||||||
);
|
);
|
||||||
|
|
||||||
let (previous_anchor, cursor) = undo.restore(&mut sel, &mut anchor);
|
let cursor = undo.restore(&mut sel, &mut anchor);
|
||||||
|
|
||||||
assert_eq!((sel, anchor), before, "selection and anchor are back");
|
assert_eq!((sel, anchor), before, "selection and anchor are back");
|
||||||
assert_eq!(previous_anchor, Some(1), "and so is the shift-click origin");
|
|
||||||
assert_eq!(cursor, Some(3), "and the keyboard cursor");
|
assert_eq!(cursor, Some(3), "and the keyboard cursor");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2960,7 +3118,7 @@ mod tests {
|
|||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 99, false, false, &span);
|
click(&mut sel, &mut anchor, &all, 0, 99, false, false, &span);
|
||||||
assert!(sel.is_empty());
|
assert!(sel.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2980,12 +3138,12 @@ mod tests {
|
|||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
// Anchor on the sixth image, in a window starting at the beginning.
|
// Anchor on the sixth image, in a window starting at the beginning.
|
||||||
apply_press(&mut sel, &mut anchor, &first, 0, 5, false, false, &span);
|
click(&mut sel, &mut anchor, &first, 0, 5, false, false, &span);
|
||||||
assert_eq!(anchor, Some(5), "the anchor is an ordinal, not a row");
|
assert_eq!(anchor, Some(5), "the anchor is an ordinal, not a row");
|
||||||
|
|
||||||
// The user scrolls — the window now starts four images in — and
|
// The user scrolls — the window now starts four images in — and
|
||||||
// shift-clicks the image at ordinal 10.
|
// shift-clicks the image at ordinal 10.
|
||||||
apply_press(&mut sel, &mut anchor, &later, 4, 6, false, true, &span);
|
click(&mut sel, &mut anchor, &later, 4, 6, false, true, &span);
|
||||||
|
|
||||||
assert_eq!(sel.len(), 6, "ordinals 5..=10");
|
assert_eq!(sel.len(), 6, "ordinals 5..=10");
|
||||||
assert!(sel.contains(&ImageId(6)), "the anchored image is still in");
|
assert!(sel.contains(&ImageId(6)), "the anchored image is still in");
|
||||||
@@ -3007,7 +3165,7 @@ mod tests {
|
|||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = Some(0);
|
let mut anchor = Some(0);
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &window, 10, 3, false, true, &span);
|
click(&mut sel, &mut anchor, &window, 10, 3, false, true, &span);
|
||||||
|
|
||||||
assert_eq!(sel.len(), 14, "ordinals 0..=13, loaded or not");
|
assert_eq!(sel.len(), 14, "ordinals 0..=13, loaded or not");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -3030,7 +3188,7 @@ mod tests {
|
|||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = Some(25);
|
let mut anchor = Some(25);
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &window, 0, 2, false, true, &span);
|
click(&mut sel, &mut anchor, &window, 0, 2, false, true, &span);
|
||||||
|
|
||||||
assert_eq!(sel.len(), 24, "ordinals 2..=25");
|
assert_eq!(sel.len(), 24, "ordinals 2..=25");
|
||||||
assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(26)));
|
assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(26)));
|
||||||
@@ -3051,7 +3209,7 @@ mod tests {
|
|||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = Some(0);
|
let mut anchor = Some(0);
|
||||||
|
|
||||||
apply_press(
|
click(
|
||||||
&mut sel,
|
&mut sel,
|
||||||
&mut anchor,
|
&mut anchor,
|
||||||
&window,
|
&window,
|
||||||
@@ -3073,7 +3231,7 @@ mod tests {
|
|||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 3, false, true, &span);
|
click(&mut sel, &mut anchor, &all, 0, 3, false, true, &span);
|
||||||
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(4)]);
|
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(4)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ pub const GESTURES: &[Gesture] = &[
|
|||||||
pointer: "Press Done in the header",
|
pointer: "Press Done in the header",
|
||||||
keys: "Escape",
|
keys: "Escape",
|
||||||
},
|
},
|
||||||
|
Gesture {
|
||||||
|
title: "Pick a photograph up to drag it",
|
||||||
|
section: "Library grid",
|
||||||
|
touch: "Press and hold it until a ring opens around it, then drag",
|
||||||
|
pointer: "Drag it",
|
||||||
|
keys: "",
|
||||||
|
},
|
||||||
Gesture {
|
Gesture {
|
||||||
title: "Select a range",
|
title: "Select a range",
|
||||||
section: "Library grid",
|
section: "Library grid",
|
||||||
|
|||||||
@@ -353,6 +353,7 @@ fn fraction(clipped: u32, pixels: u32) -> f32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: NFR-A11Y-3
|
||||||
/// How much of the frame is gone, as a figure rather than a colour.
|
/// How much of the frame is gone, as a figure rather than a colour.
|
||||||
///
|
///
|
||||||
/// NFR-A11Y-3 asks that no status be carried by hue alone, and this is the
|
/// NFR-A11Y-3 asks that no status be carried by hue alone, and this is the
|
||||||
@@ -364,6 +365,8 @@ fn fraction(clipped: u32, pixels: u32) -> f32 {
|
|||||||
/// Distinguishes "none" from "not none but under a tenth of a percent": those
|
/// Distinguishes "none" from "not none but under a tenth of a percent": those
|
||||||
/// are different answers, and rounding the second to `0.0%` would tell a
|
/// are different answers, and rounding the second to `0.0%` would tell a
|
||||||
/// photographer their highlights were safe when the indicator beside it is lit.
|
/// photographer their highlights were safe when the indicator beside it is lit.
|
||||||
|
/// `a_clipping_figure_distinguishes_none_from_nearly_none` is the test that
|
||||||
|
/// would fail if this became a colour again.
|
||||||
fn percentage(clipped: u32, pixels: u32) -> String {
|
fn percentage(clipped: u32, pixels: u32) -> String {
|
||||||
if pixels == 0 || clipped == 0 {
|
if pixels == 0 || clipped == 0 {
|
||||||
return "0%".into();
|
return "0%".into();
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ mod net_runtime;
|
|||||||
mod peaking;
|
mod peaking;
|
||||||
mod preset_store;
|
mod preset_store;
|
||||||
mod presets;
|
mod presets;
|
||||||
|
mod recovery_ui;
|
||||||
mod remote;
|
mod remote;
|
||||||
mod segmentation;
|
mod segmentation;
|
||||||
mod settings_store;
|
mod settings_store;
|
||||||
|
|||||||
+52
-11
@@ -862,7 +862,13 @@ pub fn open(
|
|||||||
|
|
||||||
// Before the scan, not after it: the grid can be filled from disk now and
|
// Before the scan, not after it: the grid can be filled from disk now and
|
||||||
// the scan is only ever going to add to it.
|
// the scan is only ever going to add to it.
|
||||||
show_catalog_now(window, &ctl, &path, &coll_ctl);
|
//
|
||||||
|
// And it is the gate on the scan, not merely a prelude to it — a damaged
|
||||||
|
// catalog has a question on screen, and a scan writing into it while that
|
||||||
|
// question is unanswered is how the last good copy gets destroyed.
|
||||||
|
if !show_catalog_now(window, &ctl, &path, &coll_ctl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let rx = library::spawn_scan(
|
let rx = library::spawn_scan(
|
||||||
conn.clone(),
|
conn.clone(),
|
||||||
@@ -1095,7 +1101,7 @@ fn drain_scan(
|
|||||||
/// the same operation: a scan is the only request that both proves the server
|
/// the same operation: a scan is the only request that both proves the server
|
||||||
/// is reachable and brings the catalog up to date. Keeping them one function
|
/// is reachable and brings the catalog up to date. Keeping them one function
|
||||||
/// is what stops "retry" from quietly becoming a weaker probe than "rescan".
|
/// is what stops "retry" from quietly becoming a weaker probe than "rescan".
|
||||||
fn start_rescan(
|
pub(crate) fn start_rescan(
|
||||||
window: &AppWindow,
|
window: &AppWindow,
|
||||||
ctl: &Rc<LibraryController>,
|
ctl: &Rc<LibraryController>,
|
||||||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||||
@@ -1916,23 +1922,38 @@ fn schedule_reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
|||||||
/// state already says "Scanning…", and an error here would contradict a scan
|
/// state already says "Scanning…", and an error here would contradict a scan
|
||||||
/// that is working perfectly. `Catalog::open` creates the file in that case, so
|
/// that is working perfectly. `Catalog::open` creates the file in that case, so
|
||||||
/// what the grid reads is an empty catalog rather than a failure.
|
/// what the grid reads is an empty catalog rather than a failure.
|
||||||
fn show_catalog_now(
|
/// Returns whether it is safe to go on and scan.
|
||||||
|
///
|
||||||
|
/// `false` means the catalog is damaged and the recovery question is up. The
|
||||||
|
/// caller must not start a scan on that answer: `Catalog::open` succeeds on a
|
||||||
|
/// file whose header survived, so the scan would write ETags and image rows
|
||||||
|
/// into damaged pages while the user is still reading the question — turning a
|
||||||
|
/// file that had a backup into one where the backup is the only copy left.
|
||||||
|
pub(crate) fn show_catalog_now(
|
||||||
window: &AppWindow,
|
window: &AppWindow,
|
||||||
ctl: &Rc<LibraryController>,
|
ctl: &Rc<LibraryController>,
|
||||||
catalog_path: &std::path::Path,
|
catalog_path: &std::path::Path,
|
||||||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||||
) {
|
) -> bool {
|
||||||
if ctl.catalog.borrow().is_some() {
|
if ctl.catalog.borrow().is_some() {
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cat = match Catalog::open(catalog_path) {
|
// Verified rather than plain: this is the once-per-launch moment where a
|
||||||
|
// full check is affordable and there is a user in front of it who can
|
||||||
|
// answer the question. See `dr_catalog::recovery` for why it is not on
|
||||||
|
// every open.
|
||||||
|
let cat = match Catalog::open_verified(catalog_path) {
|
||||||
Ok(cat) => cat,
|
Ok(cat) => cat,
|
||||||
|
Err(dr_catalog::CatalogError::Corrupt { detail }) => {
|
||||||
|
crate::recovery_ui::offer(window, catalog_path, &detail);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Not surfaced: the scan is the thing that has to work, and it is
|
// Not surfaced: the scan is the thing that has to work, and it is
|
||||||
// still running. If it fails too, it reports for both of them.
|
// still running. If it fails too, it reports for both of them.
|
||||||
log::info!("no catalog to show before the scan: {e}");
|
log::info!("no catalog to show before the scan: {e}");
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1964,6 +1985,19 @@ fn show_catalog_now(
|
|||||||
crate::collections_ui::refresh_tree(window, coll_ctl, &cat);
|
crate::collections_ui::refresh_tree(window, coll_ctl, &cat);
|
||||||
*ctl.catalog.borrow_mut() = Some(cat);
|
*ctl.catalog.borrow_mut() = Some(cat);
|
||||||
load_window(window, ctl);
|
load_window(window, ctl);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the open catalog, so the next `show_catalog_now` opens the file
|
||||||
|
/// again rather than returning early.
|
||||||
|
///
|
||||||
|
/// Only recovery needs this, and it needs it for a specific reason: the file
|
||||||
|
/// under that connection has been replaced. A handle to the catalog that was
|
||||||
|
/// there before is a handle to a file that no longer has a name, and every
|
||||||
|
/// read through it would return the damaged pages the recovery just moved out
|
||||||
|
/// of the way.
|
||||||
|
pub(crate) fn forget_catalog(ctl: &Rc<LibraryController>) {
|
||||||
|
*ctl.catalog.borrow_mut() = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What one cell of the outgoing model is worth keeping.
|
/// What one cell of the outgoing model is worth keeping.
|
||||||
@@ -4562,6 +4596,12 @@ pub fn wire<F>(
|
|||||||
let coll_for_click = coll_ctl.clone();
|
let coll_for_click = coll_ctl.clone();
|
||||||
let on_open_image = on_open_image.clone();
|
let on_open_image = on_open_image.clone();
|
||||||
window.on_library_cell_clicked(move |i| {
|
window.on_library_cell_clicked(move |i| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
|
||||||
|
// The press stayed put, so it was a tap and not a drag: whatever it
|
||||||
|
// held back can be applied now. See `collections_ui::Press`.
|
||||||
|
crate::collections_ui::commit_press(&w, &coll_for_click, &ctl.visible_ids());
|
||||||
|
|
||||||
// A ctrl- or shift-click is a selection gesture. Opening the image
|
// A ctrl- or shift-click is a selection gesture. Opening the image
|
||||||
// too would throw the user out of the grid mid-selection.
|
// too would throw the user out of the grid mid-selection.
|
||||||
if coll_for_click.press_was_modified() {
|
if coll_for_click.press_was_modified() {
|
||||||
@@ -4572,17 +4612,13 @@ pub fn wire<F>(
|
|||||||
if let Some(path) = path {
|
if let Some(path) = path {
|
||||||
// Leave the grid for the develop view. The status bar's
|
// Leave the grid for the develop view. The status bar's
|
||||||
// "‹ Library" button comes back here.
|
// "‹ Library" button comes back here.
|
||||||
if let Some(w) = weak.upgrade() {
|
|
||||||
w.set_show_library(false);
|
w.set_show_library(false);
|
||||||
// Which cell the develop view is now showing, so the photo
|
// Which cell the develop view is now showing, so the photo
|
||||||
// roll opens marking it rather than marking nothing.
|
// roll opens marking it rather than marking nothing.
|
||||||
w.set_library_roll_current(i);
|
w.set_library_roll_current(i);
|
||||||
}
|
|
||||||
on_open_image(path);
|
on_open_image(path);
|
||||||
if let Some(w) = weak.upgrade() {
|
|
||||||
report_position(&w, &ctl, i as usize);
|
report_position(&w, &ctl, i as usize);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5642,6 +5678,11 @@ pub fn wire<F>(
|
|||||||
start_rescan(&w, &ctl, &coll_ctl);
|
start_rescan(&w, &ctl, &coll_ctl);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Last, and in its own module: the answers to a damaged catalog have
|
||||||
|
// nothing to do with the library view except that they run before it
|
||||||
|
// exists.
|
||||||
|
crate::recovery_ui::wire(window, &ctl, &coll_ctl);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reload the grid after the filter changed.
|
/// Reload the grid after the filter changed.
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
//! The two offers made when the catalog turns out to be damaged.
|
||||||
|
//!
|
||||||
|
//! `dr_catalog::recovery` owns the mechanism — the integrity check, the
|
||||||
|
//! backups, the restore, setting the damaged file aside. This module owns the
|
||||||
|
//! *conversation*: what a user is told has happened, which of the two answers
|
||||||
|
//! are available, and what runs afterwards.
|
||||||
|
//!
|
||||||
|
//! # The thing that has to be said first
|
||||||
|
//!
|
||||||
|
//! **The photographs are fine, and so are the edits.** A user told that their
|
||||||
|
//! library database is corrupt will assume they have lost their work, because
|
||||||
|
//! in every other photo application they would have. Here they have not:
|
||||||
|
//! sources are read-only to this application (NFR-R4), and ratings, keywords
|
||||||
|
//! and edit graphs live in sidecars beside the images for every catalogued
|
||||||
|
//! photograph, whether or not an account exists (FR-CAT-8, invariant §5.2.4).
|
||||||
|
//! That sentence is the first line of the dialogue, before the diagnosis,
|
||||||
|
//! because it is the answer to the question the user is actually asking.
|
||||||
|
//!
|
||||||
|
//! # Why the two answers are not interchangeable
|
||||||
|
//!
|
||||||
|
//! A restore brings back **collections**; a rebuild cannot. Every other thing
|
||||||
|
//! the catalog holds has authoritative backing outside it, which is what makes
|
||||||
|
//! a rebuild survivable — but a manual collection is a set of images the user
|
||||||
|
//! assembled by hand and nothing in the filesystem records it
|
||||||
|
//! (`docs/catalog.md` §8.1). So the labels say which one loses them, and the
|
||||||
|
//! rebuild is not given the affirmative styling while a restore is on offer.
|
||||||
|
//!
|
||||||
|
//! # Why the scan is held back
|
||||||
|
//!
|
||||||
|
//! `library_ui::open` shows the catalog and then starts a scan. On a damaged
|
||||||
|
//! catalog the scan is actively harmful: a plain `Catalog::open` on a file
|
||||||
|
//! whose header is intact succeeds, and the scan would then write folder
|
||||||
|
//! ETags and image rows into damaged pages — turning a recoverable file into
|
||||||
|
//! one whose backup is the only copy left, and doing it in the seconds while
|
||||||
|
//! the user is still reading the question. So `show_catalog_now` reports
|
||||||
|
//! whether it is safe to continue, and this module restarts the scan itself
|
||||||
|
//! once the file underneath has been replaced.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use dr_catalog::recovery;
|
||||||
|
use slint::ComponentHandle;
|
||||||
|
|
||||||
|
use crate::library_ui::LibraryController;
|
||||||
|
use crate::AppWindow;
|
||||||
|
|
||||||
|
// What the open question is about.
|
||||||
|
//
|
||||||
|
// A thread-local rather than a field on `LibraryController`, because the
|
||||||
|
// question is asked *before* that controller has a catalog and is answered by
|
||||||
|
// callbacks wired at startup. Thread-local is sound here for the same reason
|
||||||
|
// `crate::memory`'s registry is: everything below runs on the Slint event
|
||||||
|
// loop thread, which is the only thread that has an `AppWindow` to show it
|
||||||
|
// on.
|
||||||
|
//
|
||||||
|
// Plain `//` rather than `///`: rustdoc does not document a macro invocation,
|
||||||
|
// and `-D warnings` rejects a doc comment that can never be rendered.
|
||||||
|
thread_local! {
|
||||||
|
static PENDING: RefCell<Option<Pending>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The damaged catalog and what can be done about it.
|
||||||
|
struct Pending {
|
||||||
|
catalog: PathBuf,
|
||||||
|
/// Newest first. Empty is the ordinary case on a young install and is not
|
||||||
|
/// an error — it removes one offer, not both.
|
||||||
|
backups: Vec<recovery::Backup>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask what should happen to a damaged catalog.
|
||||||
|
///
|
||||||
|
/// Called from `library_ui::show_catalog_now` when the startup integrity check
|
||||||
|
/// fails. `detail` is what SQLite said, carried through verbatim: a diagnosis
|
||||||
|
/// the user can quote into a bug report is worth more than a reassurance they
|
||||||
|
/// cannot check.
|
||||||
|
pub(crate) fn offer(window: &AppWindow, catalog: &Path, detail: &str) {
|
||||||
|
let backups = recovery::backups(catalog);
|
||||||
|
log::error!(
|
||||||
|
"catalog {} failed its integrity check: {detail} ({} backup(s) available)",
|
||||||
|
catalog.display(),
|
||||||
|
backups.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
window.set_recovery_title("This library's index is damaged".into());
|
||||||
|
window.set_recovery_detail(
|
||||||
|
// Two facts and their order matters: what is safe, then what is lost.
|
||||||
|
"Your photographs and your edits are safe — they are in the files \
|
||||||
|
themselves and in the sidecars beside them. What is damaged is only \
|
||||||
|
DarkRoom's index of them, which can be rebuilt."
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
window.set_recovery_diagnosis(detail.into());
|
||||||
|
|
||||||
|
match backups.first() {
|
||||||
|
Some(newest) => {
|
||||||
|
window.set_recovery_can_restore(true);
|
||||||
|
window.set_recovery_restore_label(
|
||||||
|
format!(
|
||||||
|
"Restore the backup from {} · keeps your collections",
|
||||||
|
describe_age(newest.taken_at)
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
window.set_recovery_can_restore(false);
|
||||||
|
window.set_recovery_restore_label(slint::SharedString::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.set_recovery_rebuild_label(
|
||||||
|
if backups.is_empty() {
|
||||||
|
// Nothing to compare it against, so the label states the cost
|
||||||
|
// rather than the difference.
|
||||||
|
"Rebuild from your photographs · rescans the library"
|
||||||
|
} else {
|
||||||
|
"Rebuild from your photographs · loses your collections"
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
window.set_recovery_busy(false);
|
||||||
|
// The scan was held back, so the "Scanning…" the grid is showing behind
|
||||||
|
// this would be a lie the moment the question is dismissed.
|
||||||
|
window.set_library_scanning(false);
|
||||||
|
|
||||||
|
PENDING.with(|p| {
|
||||||
|
*p.borrow_mut() = Some(Pending {
|
||||||
|
catalog: catalog.to_path_buf(),
|
||||||
|
backups,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the question without answering it.
|
||||||
|
///
|
||||||
|
/// Leaves the banner set, because the library genuinely does not work and a
|
||||||
|
/// dialogue that vanishes leaving no trace of why nothing loads is worse than
|
||||||
|
/// no dialogue at all.
|
||||||
|
fn dismiss(window: &AppWindow) {
|
||||||
|
PENDING.with(|p| *p.borrow_mut() = None);
|
||||||
|
window.set_recovery_title(slint::SharedString::new());
|
||||||
|
window.set_library_scanning(false);
|
||||||
|
window.set_library_error(
|
||||||
|
"The library index is damaged. Rescan to rebuild it, or restore a backup.".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the three answers.
|
||||||
|
///
|
||||||
|
/// Called at the end of `library_ui::wire`, which is where every other
|
||||||
|
/// window-level callback in this area is installed.
|
||||||
|
pub(crate) fn wire(
|
||||||
|
window: &AppWindow,
|
||||||
|
ctl: &Rc<LibraryController>,
|
||||||
|
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||||
|
) {
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
let coll = coll_ctl.clone();
|
||||||
|
window.on_recovery_restore(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
answer(&w, &ctl, &coll, Answer::Restore);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
let coll = coll_ctl.clone();
|
||||||
|
window.on_recovery_rebuild(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
answer(&w, &ctl, &coll, Answer::Rebuild);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
window.on_recovery_dismiss(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
dismiss(&w);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which of the two the user chose.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Answer {
|
||||||
|
Restore,
|
||||||
|
Rebuild,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Carry out an answer, then get the library going again.
|
||||||
|
///
|
||||||
|
/// Both answers end the same way — the file under `catalog_path` is one this
|
||||||
|
/// build can open — so both continue into the same two steps: open the catalog
|
||||||
|
/// for the grid, and start a scan. A rebuild needs the scan to have anything
|
||||||
|
/// at all; a restore needs it because the backup is by definition older than
|
||||||
|
/// the library.
|
||||||
|
fn answer(
|
||||||
|
window: &AppWindow,
|
||||||
|
ctl: &Rc<LibraryController>,
|
||||||
|
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||||
|
which: Answer,
|
||||||
|
) {
|
||||||
|
let Some(pending) = PENDING.with(|p| p.borrow_mut().take()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
window.set_recovery_busy(true);
|
||||||
|
|
||||||
|
// Before the file moves, not after. There is normally no open catalog here
|
||||||
|
// — `show_catalog_now` returned before storing one — but "normally" is not
|
||||||
|
// a guarantee worth resting a file rename on, and a connection to a file
|
||||||
|
// that has just been renamed out from under it reads the damaged pages
|
||||||
|
// forever.
|
||||||
|
crate::library_ui::forget_catalog(ctl);
|
||||||
|
|
||||||
|
// Synchronous, on the UI thread, and that is a considered choice rather
|
||||||
|
// than an oversight: this is a file copy of a catalog — tens of megabytes
|
||||||
|
// at 50k images — at a moment when there is nothing else on screen to
|
||||||
|
// block, no scan running, and no frame worth keeping smooth. Moving it to
|
||||||
|
// a worker would buy a spinner and cost the guarantee that nothing else
|
||||||
|
// touches the file while it is being replaced.
|
||||||
|
let outcome = match which {
|
||||||
|
Answer::Restore => match pending.backups.first() {
|
||||||
|
Some(b) => recovery::restore(&pending.catalog, &b.path),
|
||||||
|
None => Ok(()),
|
||||||
|
},
|
||||||
|
Answer::Rebuild => recovery::set_aside(&pending.catalog).map(|_| ()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = outcome {
|
||||||
|
// The question stays up: the *other* answer may still work, and a
|
||||||
|
// failed restore in particular leaves the rebuild untouched.
|
||||||
|
log::error!("recovery failed: {e}");
|
||||||
|
window.set_recovery_busy(false);
|
||||||
|
window.set_recovery_diagnosis(format!("That did not work: {e}").into());
|
||||||
|
PENDING.with(|p| *p.borrow_mut() = Some(pending));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.set_recovery_busy(false);
|
||||||
|
window.set_recovery_title(slint::SharedString::new());
|
||||||
|
window.set_library_error(slint::SharedString::new());
|
||||||
|
|
||||||
|
if crate::library_ui::show_catalog_now(window, ctl, &pending.catalog, coll_ctl) {
|
||||||
|
crate::library_ui::start_rescan(window, ctl, coll_ctl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "today", "3 days ago" — enough to choose by, without a date library.
|
||||||
|
///
|
||||||
|
/// The user is deciding how much work a restore costs them, and the answer to
|
||||||
|
/// that is an *age*, not a timestamp: "yesterday" is immediately actionable
|
||||||
|
/// and "1756512000" is not. Whole days, because an hour's precision would
|
||||||
|
/// invite a confidence the backup schedule does not earn.
|
||||||
|
fn describe_age(taken_at: i64) -> String {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let days = (now - taken_at).max(0) / 86_400;
|
||||||
|
match days {
|
||||||
|
0 => "today".to_string(),
|
||||||
|
1 => "yesterday".to_string(),
|
||||||
|
d => format!("{d} days ago"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_age_reads_as_an_age() {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
assert_eq!(describe_age(now), "today");
|
||||||
|
assert_eq!(describe_age(now - 86_400), "yesterday");
|
||||||
|
assert_eq!(describe_age(now - 5 * 86_400), "5 days ago");
|
||||||
|
// A clock that has gone backwards must not produce "-2 days ago".
|
||||||
|
assert_eq!(describe_age(now + 86_400), "today");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
// TRACES: NFR-A11Y-2
|
||||||
|
//! Every shared control says what it is, and every slider says what it edits.
|
||||||
|
//!
|
||||||
|
//! NFR-A11Y-2 asks that controls expose names, roles and values to AT-SPI and
|
||||||
|
//! TalkBack. Almost none of that is Rust: it is `accessible-*` properties in
|
||||||
|
//! Slint markup, which no unit test can observe by running the interface —
|
||||||
|
//! there is no accessibility tree without a window, and CI has no display.
|
||||||
|
//!
|
||||||
|
//! So this reads the markup, the way `darkroom-android`'s manifest test reads
|
||||||
|
//! the XML that aapt2 owns and rustc never sees. The property being defended
|
||||||
|
//! is not "the screen reader announces the right words", which needs a device
|
||||||
|
//! and a person; it is the thing that silently regresses instead — **a control
|
||||||
|
//! losing its role or its name in an ordinary refactor**, which compiles,
|
||||||
|
//! renders identically, passes every other test, and is invisible to anyone
|
||||||
|
//! not using a screen reader. That failure has already happened once here: the
|
||||||
|
//! whole application had five `accessible-*` lines in 14,482 lines of markup,
|
||||||
|
//! all of them on one row of the colour mixer, and nothing said so.
|
||||||
|
//!
|
||||||
|
//! ## What is checked, and what deliberately is not
|
||||||
|
//!
|
||||||
|
//! Two properties, both structural:
|
||||||
|
//!
|
||||||
|
//! 1. **Named components declare the roles they promise.** A `Button` that
|
||||||
|
//! stops declaring `accessible-role` stops existing for a screen reader
|
||||||
|
//! entirely, because Slint rejects every other `accessible-*` property that
|
||||||
|
//! is not accompanied by a role — so the role is the load-bearing one and
|
||||||
|
//! the rest fail with it.
|
||||||
|
//!
|
||||||
|
//! 2. **Every `SliderTrack` instantiation supplies a `label`.** This is the
|
||||||
|
//! one that catches a *new* control rather than a broken old one. The track
|
||||||
|
//! cannot name itself — it has no idea what number it is dragging — so an
|
||||||
|
//! unnamed one announces "slider, 0.35" and is the single most-used control
|
||||||
|
//! in the application. A track added without a label is the exact mistake
|
||||||
|
//! this file exists to make loud.
|
||||||
|
//!
|
||||||
|
//! Not checked: whether the words are good, whether they are translated,
|
||||||
|
//! whether contrast passes, or whether Android exposes any of it — spike S13
|
||||||
|
//! is still unrun and no test on this side of it can answer that.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// A component that must carry particular `accessible-*` properties, and the
|
||||||
|
/// properties it must carry.
|
||||||
|
///
|
||||||
|
/// Only the ones whose absence is a behavioural loss are listed. `Button` must
|
||||||
|
/// declare `accessible-action-default` because without it a screen reader can
|
||||||
|
/// read the button and not press it, which is a control that exists and cannot
|
||||||
|
/// be used; it need not declare `accessible-description`, which is a nicety.
|
||||||
|
struct Promise {
|
||||||
|
file: &'static str,
|
||||||
|
component: &'static str,
|
||||||
|
properties: &'static [&'static str],
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROMISES: &[Promise] = &[
|
||||||
|
Promise {
|
||||||
|
file: "widgets.slint",
|
||||||
|
component: "Button",
|
||||||
|
properties: &[
|
||||||
|
"accessible-role",
|
||||||
|
"accessible-label",
|
||||||
|
"accessible-action-default",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Promise {
|
||||||
|
file: "widgets.slint",
|
||||||
|
component: "IconButton",
|
||||||
|
properties: &[
|
||||||
|
"accessible-role",
|
||||||
|
"accessible-label",
|
||||||
|
"accessible-action-default",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Promise {
|
||||||
|
file: "widgets.slint",
|
||||||
|
component: "FilterChip",
|
||||||
|
properties: &[
|
||||||
|
"accessible-role",
|
||||||
|
"accessible-label",
|
||||||
|
"accessible-checked",
|
||||||
|
"accessible-action-default",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Promise {
|
||||||
|
file: "widgets.slint",
|
||||||
|
component: "Section",
|
||||||
|
properties: &["accessible-role", "accessible-label", "accessible-expanded"],
|
||||||
|
},
|
||||||
|
Promise {
|
||||||
|
file: "widgets.slint",
|
||||||
|
component: "ProgressBar",
|
||||||
|
properties: &["accessible-role", "accessible-label", "accessible-value"],
|
||||||
|
},
|
||||||
|
// The label goes on the `TextInput` inside, not on the box around it —
|
||||||
|
// Slint gives that element its role, so the name belongs beside it.
|
||||||
|
Promise {
|
||||||
|
file: "widgets.slint",
|
||||||
|
component: "Field",
|
||||||
|
properties: &["accessible-label", "accessible-placeholder-text"],
|
||||||
|
},
|
||||||
|
Promise {
|
||||||
|
file: "controls.slint",
|
||||||
|
component: "SliderTrack",
|
||||||
|
properties: &[
|
||||||
|
"accessible-role",
|
||||||
|
"accessible-label",
|
||||||
|
"accessible-value",
|
||||||
|
"accessible-value-minimum",
|
||||||
|
"accessible-value-maximum",
|
||||||
|
"accessible-action-increment",
|
||||||
|
"accessible-action-decrement",
|
||||||
|
"accessible-action-set-value",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Promise {
|
||||||
|
file: "controls.slint",
|
||||||
|
component: "Check",
|
||||||
|
properties: &[
|
||||||
|
"accessible-role",
|
||||||
|
"accessible-label",
|
||||||
|
"accessible-checked",
|
||||||
|
"accessible-action-default",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Promise {
|
||||||
|
file: "controls.slint",
|
||||||
|
component: "ChoiceChip",
|
||||||
|
properties: &[
|
||||||
|
"accessible-role",
|
||||||
|
"accessible-label",
|
||||||
|
"accessible-checked",
|
||||||
|
"accessible-action-default",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
fn ui_dir() -> PathBuf {
|
||||||
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("ui")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(path: &Path) -> String {
|
||||||
|
fs::read_to_string(path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The body of one top-level component declaration, or `None` if the file
|
||||||
|
/// declares no such component.
|
||||||
|
///
|
||||||
|
/// Every component in these files is declared at column zero, so the block
|
||||||
|
/// runs from its header to the next header or to the end of the file. A
|
||||||
|
/// looser rule than brace matching and a stricter one than "somewhere in the
|
||||||
|
/// file": the point is that a property found for `Button` really is inside
|
||||||
|
/// `Button` and not inside the component below it.
|
||||||
|
fn component_body<'a>(source: &'a str, name: &str) -> Option<&'a str> {
|
||||||
|
let header_starts =
|
||||||
|
|line: &str| line.starts_with("component ") || line.starts_with("export component ");
|
||||||
|
|
||||||
|
let mut start = None;
|
||||||
|
for (offset, line) in line_offsets(source) {
|
||||||
|
if !header_starts(line) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// `component Foo inherits Bar {` — the word after `component`.
|
||||||
|
let declared = line
|
||||||
|
.trim_start_matches("export ")
|
||||||
|
.trim_start_matches("component ")
|
||||||
|
.split_whitespace()
|
||||||
|
.next();
|
||||||
|
|
||||||
|
match start {
|
||||||
|
None if declared == Some(name) => start = Some(offset),
|
||||||
|
Some(from) => return Some(&source[from..offset]),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start.map(|from| &source[from..])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each line of `source` with its byte offset.
|
||||||
|
fn line_offsets(source: &str) -> impl Iterator<Item = (usize, &str)> {
|
||||||
|
let mut offset = 0;
|
||||||
|
source.lines().map(move |line| {
|
||||||
|
let at = offset;
|
||||||
|
offset += line.len() + 1;
|
||||||
|
(at, line)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a block sets a property — the name at the start of a line, followed
|
||||||
|
/// by what Slint puts after a name it is binding to.
|
||||||
|
///
|
||||||
|
/// Three forms, and the third is easy to forget: `name:` for a property,
|
||||||
|
/// `name =>` for a callback, and `name(arg) =>` for a callback that takes one.
|
||||||
|
/// `accessible-action-set-value` is the only member of the third group here and
|
||||||
|
/// leaving it out made this test report the slider as missing the very action
|
||||||
|
/// it declares.
|
||||||
|
///
|
||||||
|
/// Anchored at the start of the trimmed line so that a *mention* in the prose
|
||||||
|
/// above a component does not count. These files carry more comment than code,
|
||||||
|
/// and several of those comments name the very properties asserted here; a
|
||||||
|
/// substring search would pass on the strength of an explanation of the thing
|
||||||
|
/// that had been deleted, which is the failure `ui_names_no_operation.rs`
|
||||||
|
/// documents at length and the android manifest test strips comments to avoid.
|
||||||
|
fn sets(block: &str, property: &str) -> bool {
|
||||||
|
block.lines().any(|line| {
|
||||||
|
let line = line.trim_start();
|
||||||
|
line.strip_prefix(property).is_some_and(|rest| {
|
||||||
|
let rest = rest.trim_start();
|
||||||
|
rest.starts_with(':') || rest.starts_with("=>") || rest.starts_with('(')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_controls_declare_their_roles() {
|
||||||
|
let ui = ui_dir();
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
|
||||||
|
for promise in PROMISES {
|
||||||
|
let path = ui.join(promise.file);
|
||||||
|
let source = read(&path);
|
||||||
|
|
||||||
|
let Some(body) = component_body(&source, promise.component) else {
|
||||||
|
failures.push(format!(
|
||||||
|
" {} declares no component `{}` — it was renamed or removed, and \
|
||||||
|
this test then checks nothing",
|
||||||
|
promise.file, promise.component
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
for property in promise.properties {
|
||||||
|
if !sets(body, property) {
|
||||||
|
failures.push(format!(
|
||||||
|
" {}: `{}` does not set `{property}`",
|
||||||
|
promise.file, promise.component
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
failures.is_empty(),
|
||||||
|
"\n\nNFR-A11Y-2: controls expose names, roles and values to the platform \
|
||||||
|
accessibility layer.\n\n{}\n\n\
|
||||||
|
Slint rejects every `accessible-*` property that is not accompanied by an \
|
||||||
|
`accessible-role`, so the role is what makes the control exist for AT-SPI \
|
||||||
|
and TalkBack at all, and the actions are what make it usable rather than \
|
||||||
|
merely readable.\n\n\
|
||||||
|
These live on the shared components rather than at the call sites \
|
||||||
|
deliberately: a control annotated where it is used is a control unnamed \
|
||||||
|
everywhere it is used next. See the preamble to widgets.slint.\n",
|
||||||
|
failures.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_slider_track_is_named() {
|
||||||
|
let ui = ui_dir();
|
||||||
|
let mut files: Vec<PathBuf> = fs::read_dir(&ui)
|
||||||
|
.unwrap_or_else(|e| panic!("cannot read {}: {e}", ui.display()))
|
||||||
|
.map(|entry| entry.expect("read dir entry").path())
|
||||||
|
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("slint"))
|
||||||
|
.collect();
|
||||||
|
files.sort();
|
||||||
|
|
||||||
|
let mut instantiations = 0usize;
|
||||||
|
let mut unnamed = Vec::new();
|
||||||
|
|
||||||
|
for path in &files {
|
||||||
|
let source = read(path);
|
||||||
|
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
|
||||||
|
|
||||||
|
// The declaration itself, in controls.slint, is not an instantiation.
|
||||||
|
for (offset, line) in line_offsets(&source) {
|
||||||
|
if line.trim_start() != "SliderTrack {" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
instantiations += 1;
|
||||||
|
|
||||||
|
let block = &source[offset..offset + block_len(&source[offset..])];
|
||||||
|
if !sets(block, "label") {
|
||||||
|
let number = source[..offset].lines().count() + 1;
|
||||||
|
unnamed.push(format!(" {name}:{number}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A scan that found nothing passes for the wrong reason. Four tracks are
|
||||||
|
// instantiated today — the develop panel's three shapes and the settings
|
||||||
|
// page's row — and the floor sits below that and well above zero, so
|
||||||
|
// removing one is a code change and not a silent scan failure.
|
||||||
|
assert!(
|
||||||
|
instantiations >= 3,
|
||||||
|
"found only {instantiations} `SliderTrack` instantiations — the scan is \
|
||||||
|
matching nothing, and a scan over nothing passes"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
unnamed.is_empty(),
|
||||||
|
"\n\nNFR-A11Y-2: a slider that does not say what it adjusts.\n\n{}\n\n\
|
||||||
|
`SliderTrack` cannot name itself — it is handed four numbers and knows \
|
||||||
|
nothing about what they mean — so a track without a `label` announces as \
|
||||||
|
\"slider, 0.35\". The develop column holds thirty-six of them in the \
|
||||||
|
colour mixer alone, which is thirty-six controls a screen reader cannot \
|
||||||
|
tell apart.\n\n\
|
||||||
|
Pass the same string the row is already drawing above the track.\n",
|
||||||
|
unnamed.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The length of the brace-delimited block beginning at the start of `rest`,
|
||||||
|
/// including both braces.
|
||||||
|
///
|
||||||
|
/// Braces inside string literals are not handled, and do not occur inside a
|
||||||
|
/// `SliderTrack` instantiation — the only strings there are labels and units.
|
||||||
|
fn block_len(rest: &str) -> usize {
|
||||||
|
let mut depth = 0usize;
|
||||||
|
for (i, c) in rest.char_indices() {
|
||||||
|
match c {
|
||||||
|
'{' => depth += 1,
|
||||||
|
'}' => {
|
||||||
|
depth -= 1;
|
||||||
|
if depth == 0 {
|
||||||
|
return i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rest.len()
|
||||||
|
}
|
||||||
+56
-10
@@ -140,6 +140,18 @@ component GroupHeading inherits Rectangle {
|
|||||||
width: 34px;
|
width: 34px;
|
||||||
visible: root.has-reset;
|
visible: root.has-reset;
|
||||||
|
|
||||||
|
// A `Caption` is a `Text`, which Slint announces as text — so
|
||||||
|
// without this the reset reads as the word "reset" sitting beside
|
||||||
|
// the heading rather than as something that can be pressed.
|
||||||
|
accessible-role: button;
|
||||||
|
accessible-label: "Reset";
|
||||||
|
accessible-enabled: root.has-reset;
|
||||||
|
accessible-action-default => {
|
||||||
|
if (root.has-reset) {
|
||||||
|
root.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
reset-touch := TouchArea {
|
reset-touch := TouchArea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: max(parent.height, Theme.touch-target);
|
height: max(parent.height, Theme.touch-target);
|
||||||
@@ -202,6 +214,18 @@ component ParamSlider inherits Rectangle {
|
|||||||
modified: root.data.value != root.data.default-value;
|
modified: root.data.value != root.data.default-value;
|
||||||
|
|
||||||
SliderTrack {
|
SliderTrack {
|
||||||
|
// The same two strings `ControlRow` is drawing above the track.
|
||||||
|
// Drawn text and announced text are separate channels — Slint
|
||||||
|
// associates neither with the control on its own — so the label a
|
||||||
|
// sighted user reads and the label a screen reader hears come from
|
||||||
|
// one expression each, rather than the second being left empty.
|
||||||
|
label: root.data.param-label;
|
||||||
|
readout: Readout.of(root.data);
|
||||||
|
// The descriptor's declared precision, as a step: a parameter in
|
||||||
|
// whole units nudges by one and one in stops by a hundredth,
|
||||||
|
// which is the same quantum the readout is rounded to.
|
||||||
|
step: root.data.precision == 0 ? 1.0 : 0.01;
|
||||||
|
|
||||||
value: root.data.value;
|
value: root.data.value;
|
||||||
default-value: root.data.default-value;
|
default-value: root.data.default-value;
|
||||||
minimum: root.data.minimum;
|
minimum: root.data.minimum;
|
||||||
@@ -245,12 +269,18 @@ component FacetHeading inherits Rectangle {
|
|||||||
// most of a screen of scrolling with the band name absent from every row of it
|
// most of a screen of scrolling with the band name absent from every row of it
|
||||||
// anyway.
|
// anyway.
|
||||||
//
|
//
|
||||||
// **The name is not thrown away, it moves.** It is the row's accessible label,
|
// **The name is not thrown away, it moves.** It becomes the track's accessible
|
||||||
// so a screen reader says "Orange" where the eye reads the colour, and the
|
// label, so a screen reader says "Orange" where the eye reads the colour, and
|
||||||
// catalogue in `labels.rs` is where the mapping is written down for anyone who
|
// the catalogue in `labels.rs` is where the mapping is written down for anyone
|
||||||
// cannot separate two squares by eye. A row identified by colour *alone*
|
// who cannot separate two squares by eye. A row identified by colour *alone*
|
||||||
// would be a control some photographers could not use, which is why the
|
// would be a control some photographers could not use, which is why the
|
||||||
// spoken name is part of the design and not an afterthought.
|
// spoken name is part of the design and not an afterthought.
|
||||||
|
//
|
||||||
|
// It used to be announced on this row, with the track below it silent. Now
|
||||||
|
// that every `SliderTrack` names itself (NFR-A11Y-2) the two would nest — a
|
||||||
|
// slider inside a slider, the outer one carrying the value and the inner one
|
||||||
|
// carrying the actions that can change it — so the row stands down and hands
|
||||||
|
// the same three strings to the control that owns the gesture.
|
||||||
component SwatchSlider inherits Rectangle {
|
component SwatchSlider inherits Rectangle {
|
||||||
in property <ParamRow> data;
|
in property <ParamRow> data;
|
||||||
callback changed(float);
|
callback changed(float);
|
||||||
@@ -264,12 +294,6 @@ component SwatchSlider inherits Rectangle {
|
|||||||
// and the gestures behind it (FR-UI-3).
|
// and the gestures behind it (FR-UI-3).
|
||||||
height: Theme.touch-target / 2 + 4px;
|
height: Theme.touch-target / 2 + 4px;
|
||||||
|
|
||||||
accessible-role: slider;
|
|
||||||
accessible-label: root.data.param-label;
|
|
||||||
accessible-value: Readout.of(root.data);
|
|
||||||
accessible-value-minimum: root.data.minimum;
|
|
||||||
accessible-value-maximum: root.data.maximum;
|
|
||||||
|
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
padding-left: Theme.gap-sm;
|
padding-left: Theme.gap-sm;
|
||||||
spacing: Theme.gap-sm;
|
spacing: Theme.gap-sm;
|
||||||
@@ -284,6 +308,8 @@ component SwatchSlider inherits Rectangle {
|
|||||||
SliderTrack {
|
SliderTrack {
|
||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
|
|
||||||
|
label: root.data.param-label;
|
||||||
|
readout: Readout.of(root.data);
|
||||||
value: root.data.value;
|
value: root.data.value;
|
||||||
default-value: root.data.default-value;
|
default-value: root.data.default-value;
|
||||||
minimum: root.data.minimum;
|
minimum: root.data.minimum;
|
||||||
@@ -346,6 +372,10 @@ component PlainSlider inherits Rectangle {
|
|||||||
modified: root.value != root.default-value;
|
modified: root.value != root.default-value;
|
||||||
|
|
||||||
SliderTrack {
|
SliderTrack {
|
||||||
|
label: root.label;
|
||||||
|
readout: (Math.round(root.value * 10) / 10) + root.unit;
|
||||||
|
step: 0.1;
|
||||||
|
|
||||||
value: root.value;
|
value: root.value;
|
||||||
default-value: root.default-value;
|
default-value: root.default-value;
|
||||||
minimum: root.minimum;
|
minimum: root.minimum;
|
||||||
@@ -575,16 +605,26 @@ export component GeometryPanel inherits Rectangle {
|
|||||||
// Rotation and flips. Icons rather than labels: four controls
|
// Rotation and flips. Icons rather than labels: four controls
|
||||||
// named in words would wrap the 280px column, and each of
|
// named in words would wrap the 280px column, and each of
|
||||||
// these shows its own result.
|
// these shows its own result.
|
||||||
|
//
|
||||||
|
// The words are still written, once each, as `label` — the width
|
||||||
|
// argument is about the column, and a screen reader has no column.
|
||||||
|
// The two flips also declare themselves checkable, which
|
||||||
|
// `IconButton` cannot do on its own: `active` says a toggle is on
|
||||||
|
// and says nothing at all about whether an inactive control is a
|
||||||
|
// toggle that is off, so only these call sites know that the two
|
||||||
|
// rotations are not toggles and these two are.
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
spacing: Theme.gap-sm;
|
spacing: Theme.gap-sm;
|
||||||
|
|
||||||
IconButton {
|
IconButton {
|
||||||
icon: "rotate-ccw";
|
icon: "rotate-ccw";
|
||||||
|
label: "Rotate left";
|
||||||
enabled: root.enabled;
|
enabled: root.enabled;
|
||||||
clicked => { root.rotate(-1); }
|
clicked => { root.rotate(-1); }
|
||||||
}
|
}
|
||||||
IconButton {
|
IconButton {
|
||||||
icon: "rotate-cw";
|
icon: "rotate-cw";
|
||||||
|
label: "Rotate right";
|
||||||
enabled: root.enabled;
|
enabled: root.enabled;
|
||||||
clicked => { root.rotate(1); }
|
clicked => { root.rotate(1); }
|
||||||
}
|
}
|
||||||
@@ -593,13 +633,19 @@ export component GeometryPanel inherits Rectangle {
|
|||||||
|
|
||||||
IconButton {
|
IconButton {
|
||||||
icon: "flip-h";
|
icon: "flip-h";
|
||||||
|
label: "Flip horizontally";
|
||||||
active: root.flip-h;
|
active: root.flip-h;
|
||||||
|
accessible-checkable: true;
|
||||||
|
accessible-checked: root.flip-h;
|
||||||
enabled: root.enabled;
|
enabled: root.enabled;
|
||||||
clicked => { root.flip-h-toggled(); }
|
clicked => { root.flip-h-toggled(); }
|
||||||
}
|
}
|
||||||
IconButton {
|
IconButton {
|
||||||
icon: "flip-v";
|
icon: "flip-v";
|
||||||
|
label: "Flip vertically";
|
||||||
active: root.flip-v;
|
active: root.flip-v;
|
||||||
|
accessible-checkable: true;
|
||||||
|
accessible-checked: root.flip-v;
|
||||||
enabled: root.enabled;
|
enabled: root.enabled;
|
||||||
clicked => { root.flip-v-toggled(); }
|
clicked => { root.flip-v-toggled(); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { GestureRow } from "gestures.slint";
|
|||||||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
||||||
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
|
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
|
||||||
import { HistogramPanel, HistogramView } from "histogram.slint";
|
import { HistogramPanel, HistogramView } from "histogram.slint";
|
||||||
|
import { RecoveryPrompt } from "recovery.slint";
|
||||||
import { PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
|
import { PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
|
||||||
import { FocusMarks, FocusPanel } from "peaking.slint";
|
import { FocusMarks, FocusPanel } from "peaking.slint";
|
||||||
import { SettingsPage } from "settings.slint";
|
import { SettingsPage } from "settings.slint";
|
||||||
@@ -365,6 +366,21 @@ export component AppWindow inherits Window {
|
|||||||
callback offline-prompt-release();
|
callback offline-prompt-release();
|
||||||
callback offline-prompt-dismiss();
|
callback offline-prompt-dismiss();
|
||||||
|
|
||||||
|
// The question a damaged catalog asks. Same shape as the prompt above and
|
||||||
|
// for the same reason: an empty title is what closes it, and every word in
|
||||||
|
// it is composed in Rust, which is the only side that knows what SQLite
|
||||||
|
// said and which backups exist.
|
||||||
|
in property <string> recovery-title: "";
|
||||||
|
in property <string> recovery-detail: "";
|
||||||
|
in property <string> recovery-diagnosis: "";
|
||||||
|
in property <string> recovery-restore-label: "";
|
||||||
|
in property <bool> recovery-can-restore: false;
|
||||||
|
in property <string> recovery-rebuild-label: "";
|
||||||
|
in property <bool> recovery-busy: false;
|
||||||
|
callback recovery-restore();
|
||||||
|
callback recovery-rebuild();
|
||||||
|
callback recovery-dismiss();
|
||||||
|
|
||||||
in property <string> library-root-label: "";
|
in property <string> library-root-label: "";
|
||||||
in-out property <[TimelineBar]> library-timeline;
|
in-out property <[TimelineBar]> library-timeline;
|
||||||
in property <string> library-timeline-label: "";
|
in property <string> library-timeline-label: "";
|
||||||
@@ -543,6 +559,13 @@ export component AppWindow inherits Window {
|
|||||||
/// that turns it on. The long press does the same thing without it.
|
/// that turns it on. The long press does the same thing without it.
|
||||||
in property <bool> library-select-mode: false;
|
in property <bool> library-select-mode: false;
|
||||||
callback library-toggle-select-mode();
|
callback library-toggle-select-mode();
|
||||||
|
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||||
|
/// The row a press has held long enough to pick up, or `-1`.
|
||||||
|
///
|
||||||
|
/// Set by the same hold that turns on selection mode and cleared by the
|
||||||
|
/// grid when the press ends — `in-out` because both ends write it. See
|
||||||
|
/// `held-row` in `library.slint` for what it draws and what it stops.
|
||||||
|
in-out property <int> library-held-row: -1;
|
||||||
/// A press on a cell ended, so the long-press timer can be cancelled.
|
/// A press on a cell ended, so the long-press timer can be cancelled.
|
||||||
callback library-cell-press-ended();
|
callback library-cell-press-ended();
|
||||||
/// TRACES: FR-UI-2 | FR-UI-4
|
/// TRACES: FR-UI-2 | FR-UI-4
|
||||||
@@ -1145,6 +1168,14 @@ in property <bool> panel-visible: true;
|
|||||||
// would leave the library from behind an open question — the
|
// would leave the library from behind an open question — the
|
||||||
// view changing underneath a modal, which reads as the app
|
// view changing underneath a modal, which reads as the app
|
||||||
// having lost its place.
|
// having lost its place.
|
||||||
|
//
|
||||||
|
// The recovery question is asked first because it is drawn
|
||||||
|
// over everything, the offline prompt included: Back must
|
||||||
|
// reach the thing the user can actually see.
|
||||||
|
if (root.recovery-title != "") {
|
||||||
|
root.recovery-dismiss();
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
if (root.offline-prompt-title != "") {
|
if (root.offline-prompt-title != "") {
|
||||||
root.offline-prompt-dismiss();
|
root.offline-prompt-dismiss();
|
||||||
return accept;
|
return accept;
|
||||||
@@ -1585,6 +1616,7 @@ in property <bool> panel-visible: true;
|
|||||||
cell-press-ended() => { root.library-cell-press-ended(); }
|
cell-press-ended() => { root.library-cell-press-ended(); }
|
||||||
select-mode: root.library-select-mode;
|
select-mode: root.library-select-mode;
|
||||||
toggle-select-mode() => { root.library-toggle-select-mode(); }
|
toggle-select-mode() => { root.library-toggle-select-mode(); }
|
||||||
|
held-row <=> root.library-held-row;
|
||||||
// The sidebar's rows, not a second model: the sheet files
|
// The sidebar's rows, not a second model: the sheet files
|
||||||
// into the same tree the sidebar draws.
|
// into the same tree the sidebar draws.
|
||||||
collections: root.collection-rows;
|
collections: root.collection-rows;
|
||||||
@@ -2670,5 +2702,24 @@ in property <bool> panel-visible: true;
|
|||||||
release() => { root.offline-prompt-release(); }
|
release() => { root.offline-prompt-release(); }
|
||||||
dismiss() => { root.offline-prompt-dismiss(); }
|
dismiss() => { root.offline-prompt-dismiss(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Last, and therefore over everything including the settings page and
|
||||||
|
// the offline prompt. Not a preference about layering: this is asked
|
||||||
|
// before the grid exists, and nothing else in the window is about a
|
||||||
|
// library that can be read.
|
||||||
|
RecoveryPrompt {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
title: root.recovery-title;
|
||||||
|
detail: root.recovery-detail;
|
||||||
|
diagnosis: root.recovery-diagnosis;
|
||||||
|
restore-label: root.recovery-restore-label;
|
||||||
|
can-restore: root.recovery-can-restore;
|
||||||
|
rebuild-label: root.recovery-rebuild-label;
|
||||||
|
busy: root.recovery-busy;
|
||||||
|
restore() => { root.recovery-restore(); }
|
||||||
|
rebuild() => { root.recovery-rebuild(); }
|
||||||
|
dismiss() => { root.recovery-dismiss(); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,16 @@
|
|||||||
// primitives take plain numbers and strings, and the ParamRow-shaped wrappers
|
// primitives take plain numbers and strings, and the ParamRow-shaped wrappers
|
||||||
// stay in the panel that owns the model. This is the constraint that makes the
|
// stay in the panel that owns the model. This is the constraint that makes the
|
||||||
// file reusable, so it is worth stating rather than merely observing.
|
// file reusable, so it is worth stating rather than merely observing.
|
||||||
|
//
|
||||||
|
// **Accessibility follows the same rule as behaviour** (NFR-A11Y-2, and see
|
||||||
|
// widgets.slint's preamble for the two Slint constraints that shape it): a
|
||||||
|
// control's role, and the actions assistive technology can invoke on it, are
|
||||||
|
// written once here. Its *name* is the one thing that cannot be — a track has
|
||||||
|
// no idea what number it is dragging — so every primitive below takes a
|
||||||
|
// `label`, and the wrappers that do know pass it down. An unnamed control is
|
||||||
|
// the failure mode that matters: a screen reader announcing "slider, 0.35" for
|
||||||
|
// each of the thirty-six controls in the colour mixer has told the user
|
||||||
|
// nothing at all.
|
||||||
|
|
||||||
import { Theme } from "theme.slint";
|
import { Theme } from "theme.slint";
|
||||||
import { Icon, Label, Value, Caption, Field } from "widgets.slint";
|
import { Icon, Label, Value, Caption, Field } from "widgets.slint";
|
||||||
@@ -49,6 +59,30 @@ export component SliderTrack inherits Rectangle {
|
|||||||
in property <float> minimum;
|
in property <float> minimum;
|
||||||
in property <float> maximum;
|
in property <float> maximum;
|
||||||
|
|
||||||
|
/// What this track adjusts. The track's accessible name.
|
||||||
|
///
|
||||||
|
/// Every wrapper already draws this word somewhere — `ControlRow` puts it
|
||||||
|
/// above, `FieldRow` puts it above and to the left — and none of those
|
||||||
|
/// placements associates it with the control as far as the platform is
|
||||||
|
/// concerned. `SwatchSlider` is the case that makes the point: it draws no
|
||||||
|
/// word at all, only a coloured square, and its name has *always* had to
|
||||||
|
/// travel this way.
|
||||||
|
in property <string> label;
|
||||||
|
/// The value as the user should hear it, already formatted.
|
||||||
|
///
|
||||||
|
/// Empty falls back to the raw number, which is right for a track whose
|
||||||
|
/// caller has nothing better; a caller with a declared precision and a
|
||||||
|
/// unit hands over what it is drawing, so "+1.25 EV" is announced rather
|
||||||
|
/// than "1.2500000298".
|
||||||
|
///
|
||||||
|
/// A string rather than a float for the reason `ControlRow.readout` gives:
|
||||||
|
/// precision belongs to whoever owns the value, and a control that rounded
|
||||||
|
/// on its own would announce a parameter one way and draw it another.
|
||||||
|
in property <string> readout;
|
||||||
|
/// How far one assistive-technology nudge moves the value. Zero takes a
|
||||||
|
/// hundredth of the range, which is the resolution a drag has anyway.
|
||||||
|
in property <float> step: 0;
|
||||||
|
|
||||||
/// Live, once per movement. For anything that should follow the drag: a
|
/// Live, once per movement. For anything that should follow the drag: a
|
||||||
/// readout, a preview, the image itself.
|
/// readout, a preview, the image itself.
|
||||||
callback changed(float);
|
callback changed(float);
|
||||||
@@ -76,6 +110,47 @@ export component SliderTrack inherits Rectangle {
|
|||||||
// by nothing and put every position at infinity.
|
// by nothing and put every position at infinity.
|
||||||
property <float> span: max(0.000001, root.maximum - root.minimum);
|
property <float> span: max(0.000001, root.maximum - root.minimum);
|
||||||
|
|
||||||
|
property <float> nudge: root.step > 0 ? root.step : root.span / 100;
|
||||||
|
|
||||||
|
// **One nudge is a whole gesture, so it commits.**
|
||||||
|
//
|
||||||
|
// The two callbacks exist because a drag is many movements and one
|
||||||
|
// decision (see `committed` above). An arrow key pressed once is both at
|
||||||
|
// the same time: there is no stream to debounce and no release to wait
|
||||||
|
// for, so a nudge that only fired `changed` would move the photograph and
|
||||||
|
// never be saved by any caller that listens for the end of a drag — which
|
||||||
|
// is every settings-shaped caller in the application.
|
||||||
|
function move-to(v: float) {
|
||||||
|
root.changed(clamp(v, root.minimum, root.maximum));
|
||||||
|
root.committed(clamp(v, root.minimum, root.maximum));
|
||||||
|
}
|
||||||
|
|
||||||
|
// **The only route to this control that is not a pointer.** ui-navigation
|
||||||
|
// D-N2 rules out hover as the sole affordance; a control reachable only by
|
||||||
|
// dragging it is the same objection with the pointer itself as the
|
||||||
|
// modifier. These three actions are what AT-SPI and TalkBack drive a
|
||||||
|
// slider with, and they are what makes the track adjustable rather than
|
||||||
|
// merely readable.
|
||||||
|
accessible-role: slider;
|
||||||
|
accessible-label: root.label;
|
||||||
|
// Both arms of the ternary must be strings — the empty concatenation is
|
||||||
|
// what makes the fallback one.
|
||||||
|
accessible-value: root.readout != "" ? root.readout : (root.value + "");
|
||||||
|
accessible-value-minimum: root.minimum;
|
||||||
|
accessible-value-maximum: root.maximum;
|
||||||
|
accessible-value-step: root.nudge;
|
||||||
|
accessible-action-increment => { root.move-to(root.value + root.nudge); }
|
||||||
|
accessible-action-decrement => { root.move-to(root.value - root.nudge); }
|
||||||
|
accessible-action-set-value(v) => {
|
||||||
|
// `is-float()` for the reason `NumberField` gives at length:
|
||||||
|
// `to-float()` answers 0 for a string it could not parse, and a
|
||||||
|
// screen reader handing over "abc" would silently set the exposure to
|
||||||
|
// zero rather than reject the entry.
|
||||||
|
if (v.is-float()) {
|
||||||
|
root.move-to(v.to-float());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// **Why hover, and not the drag itself.**
|
// **Why hover, and not the drag itself.**
|
||||||
//
|
//
|
||||||
// A Flickable does not merely compete for a gesture, it *withholds* the
|
// A Flickable does not merely compete for a gesture, it *withholds* the
|
||||||
@@ -258,6 +333,9 @@ export component NumberField inherits Rectangle {
|
|||||||
in property <float> value;
|
in property <float> value;
|
||||||
in property <float> minimum;
|
in property <float> minimum;
|
||||||
in property <float> maximum;
|
in property <float> maximum;
|
||||||
|
/// What the number means. Handed straight to the entry, which is where the
|
||||||
|
/// accessibility tree wants it — see `Field.label`.
|
||||||
|
in property <string> label;
|
||||||
/// Decimal places shown, and the precision an entry is held to. Zero for a
|
/// Decimal places shown, and the precision an entry is held to. Zero for a
|
||||||
/// count, two for a value in stops — the same figure a parameter
|
/// count, two for a value in stops — the same figure a parameter
|
||||||
/// descriptor declares.
|
/// descriptor declares.
|
||||||
@@ -303,6 +381,7 @@ export component NumberField inherits Rectangle {
|
|||||||
field := Field {
|
field := Field {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
label: root.label;
|
||||||
text <=> root.text;
|
text <=> root.text;
|
||||||
// Committed on Enter *and* on losing focus, matching `TextRow`: Enter
|
// Committed on Enter *and* on losing focus, matching `TextRow`: Enter
|
||||||
// alone loses the edit the moment the user clicks the next control,
|
// alone loses the edit the moment the user clicks the next control,
|
||||||
@@ -344,6 +423,21 @@ export component Check inherits Rectangle {
|
|||||||
|
|
||||||
callback toggled(bool);
|
callback toggled(bool);
|
||||||
|
|
||||||
|
// The hint becomes the description rather than part of the name. It exists
|
||||||
|
// to say what a setting *costs* — "location is stripped", "upscaling is
|
||||||
|
// off" — which is the second thing a reader wants and never the first, and
|
||||||
|
// a name that carried it would read the whole sentence back on every pass
|
||||||
|
// through the page.
|
||||||
|
accessible-role: checkbox;
|
||||||
|
accessible-label: root.label;
|
||||||
|
accessible-description: root.hint;
|
||||||
|
accessible-checkable: true;
|
||||||
|
accessible-checked: root.checked;
|
||||||
|
accessible-action-default => {
|
||||||
|
root.checked = !root.checked;
|
||||||
|
root.toggled(root.checked);
|
||||||
|
}
|
||||||
|
|
||||||
height: max(row.preferred-height, Theme.control-height);
|
height: max(row.preferred-height, Theme.control-height);
|
||||||
|
|
||||||
touch := TouchArea {
|
touch := TouchArea {
|
||||||
@@ -408,6 +502,31 @@ export component ChoiceChip inherits Rectangle {
|
|||||||
|
|
||||||
callback clicked();
|
callback clicked();
|
||||||
|
|
||||||
|
// `radio-button` and not `button`, because single-selection over a fixed
|
||||||
|
// list is what a radio button *is* — and the difference is audible: a
|
||||||
|
// reader announcing "radio button, selected" has told the user that
|
||||||
|
// picking another one will unpick this, which "button, pressed" has not.
|
||||||
|
// It is the same distinction the prose above draws against `FilterChip`,
|
||||||
|
// said in the vocabulary the platform already has a word for.
|
||||||
|
//
|
||||||
|
// **No `radio-group` around them.** The role exists, and the natural home
|
||||||
|
// for it — `Segmented` — is a `VerticalLayout` rather than the plain root
|
||||||
|
// Slint's own `RadioGroupBase` carries it on, and `Segmented` delegates to
|
||||||
|
// `ChipGrid` for a wrapped set, so the group would either sit on a layout
|
||||||
|
// element or be declared twice and nest. The group's name still reaches a
|
||||||
|
// reader: `FieldRow` draws it as a `Text`, which Slint exposes on its own,
|
||||||
|
// immediately before the chips in traversal order.
|
||||||
|
accessible-role: radio-button;
|
||||||
|
accessible-label: root.label;
|
||||||
|
accessible-enabled: root.enabled;
|
||||||
|
accessible-checkable: true;
|
||||||
|
accessible-checked: root.selected;
|
||||||
|
accessible-action-default => {
|
||||||
|
if (root.enabled) {
|
||||||
|
root.clicked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
height: Theme.control-height;
|
height: Theme.control-height;
|
||||||
// Wide enough that a one-word label is still a comfortable target, which
|
// Wide enough that a one-word label is still a comfortable target, which
|
||||||
// is what `control-min-width` exists for — but chips sit several to a row,
|
// is what `control-min-width` exists for — but chips sit several to a row,
|
||||||
@@ -612,6 +731,7 @@ export component TextRow inherits VerticalLayout {
|
|||||||
|
|
||||||
field := Field {
|
field := Field {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
label: root.label;
|
||||||
text <=> root.text;
|
text <=> root.text;
|
||||||
placeholder: root.placeholder;
|
placeholder: root.placeholder;
|
||||||
// Committed on Enter *and* on losing focus. Enter alone loses
|
// Committed on Enter *and* on losing focus. Enter alone loses
|
||||||
@@ -755,6 +875,14 @@ export component SliderRow inherits VerticalLayout {
|
|||||||
// tall where the track is half of one.
|
// tall where the track is half of one.
|
||||||
y: (parent.height - self.height) / 2;
|
y: (parent.height - self.height) / 2;
|
||||||
|
|
||||||
|
label: root.label;
|
||||||
|
// A declared precision is a declared step — the argument above,
|
||||||
|
// reused. The nudge an assistive technology makes is therefore the
|
||||||
|
// same quantum a drag snaps to, so arrowing to a value and
|
||||||
|
// dragging to it produce the same number rather than two that
|
||||||
|
// differ in the last place.
|
||||||
|
step: 1.0 / root.step-factor;
|
||||||
|
|
||||||
value: root.live;
|
value: root.live;
|
||||||
default-value: root.default-value;
|
default-value: root.default-value;
|
||||||
minimum: root.minimum;
|
minimum: root.minimum;
|
||||||
@@ -768,6 +896,7 @@ export component SliderRow inherits VerticalLayout {
|
|||||||
NumberField {
|
NumberField {
|
||||||
// Follows the drag, so the number and the handle never disagree.
|
// Follows the drag, so the number and the handle never disagree.
|
||||||
value: root.live;
|
value: root.live;
|
||||||
|
label: root.label;
|
||||||
minimum: root.minimum;
|
minimum: root.minimum;
|
||||||
maximum: root.maximum;
|
maximum: root.maximum;
|
||||||
precision: root.precision;
|
precision: root.precision;
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ component Trace inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: NFR-A11Y-3
|
||||||
// A clipping readout: a lit marker and a figure.
|
// A clipping readout: a lit marker and a figure.
|
||||||
//
|
//
|
||||||
// **Two affordances for one fact, and NFR-A11Y-3 is why.** No status in this
|
// **Two affordances for one fact, and NFR-A11Y-3 is why.** No status in this
|
||||||
|
|||||||
@@ -155,12 +155,19 @@ component FaceCell inherits Rectangle {
|
|||||||
// judgement, and the two are never conflated. A
|
// judgement, and the two are never conflated. A
|
||||||
// rejection is remembered, so the face is not suggested
|
// rejection is remembered, so the face is not suggested
|
||||||
// for that person again.
|
// for that person again.
|
||||||
|
// The gesture note above is the whole label: a tick and a cross
|
||||||
|
// are only "confirm" and "reject" to someone who can see the
|
||||||
|
// suggestion they sit beside, and `IconButton`'s fallback would
|
||||||
|
// announce them as "check" and "cross" — two icon names that say
|
||||||
|
// nothing about which person is being ruled on.
|
||||||
if !face.confirmed: IconButton {
|
if !face.confirmed: IconButton {
|
||||||
icon: "check";
|
icon: "check";
|
||||||
|
label: "Confirm this face";
|
||||||
clicked => { root.confirm(); }
|
clicked => { root.confirm(); }
|
||||||
}
|
}
|
||||||
if !face.confirmed: IconButton {
|
if !face.confirmed: IconButton {
|
||||||
icon: "cross";
|
icon: "cross";
|
||||||
|
label: "Reject this face";
|
||||||
clicked => { root.reject(); }
|
clicked => { root.reject(); }
|
||||||
}
|
}
|
||||||
if face.confirmed: Text {
|
if face.confirmed: Text {
|
||||||
@@ -750,6 +757,7 @@ export component IdentityScreen inherits Rectangle {
|
|||||||
Rectangle { }
|
Rectangle { }
|
||||||
IconButton {
|
IconButton {
|
||||||
icon: "rotate-cw";
|
icon: "rotate-cw";
|
||||||
|
label: "Recheck coverage";
|
||||||
clicked => { root.check-coverage(); }
|
clicked => { root.check-coverage(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+75
-31
@@ -7,6 +7,30 @@ import { Check } from "controls.slint";
|
|||||||
// Deliberately separate from AppWindow. It is the first thing a user sees
|
// Deliberately separate from AppWindow. It is the first thing a user sees
|
||||||
// with no library configured, and the place they return to in order to sign
|
// with no library configured, and the place they return to in order to sign
|
||||||
// out or switch account (FR-NC-1, FR-NC-4).
|
// out or switch account (FR-NC-1, FR-NC-4).
|
||||||
|
//
|
||||||
|
// **The first screen converted to `@tr()`** (NFR-A11Y-1), and this one first
|
||||||
|
// because it is the one a user cannot get past: an interface they cannot read
|
||||||
|
// is unusable here in a way it is not in a preferences page they could ignore.
|
||||||
|
// The mechanism, the extraction command and the reason a build with no
|
||||||
|
// translation behaves identically are in `build.rs`.
|
||||||
|
//
|
||||||
|
// Four kinds of string are deliberately *not* wrapped, and the distinction is
|
||||||
|
// worth stating because "wrap every literal" is the obvious rule and the wrong
|
||||||
|
// one:
|
||||||
|
//
|
||||||
|
// - **"DarkRoom".** A product name, the same in every language. Translating
|
||||||
|
// it invites a translator to answer the question, and there is no answer.
|
||||||
|
// - **Example values** — `https://cloud.example.com`, `/home/you/Pictures`,
|
||||||
|
// the app-password mask. These are shapes rather than sentences; a
|
||||||
|
// translated hostname would teach the wrong format.
|
||||||
|
// - **`".."`**, the row that leads out of a folder. A filesystem convention,
|
||||||
|
// not a word.
|
||||||
|
// - **`"/"`**, the path separator.
|
||||||
|
//
|
||||||
|
// The headings are wrapped and carry their own capitals — `PanelHeading` draws
|
||||||
|
// what it is given — so a translator supplies "SERVEUR" rather than "Serveur".
|
||||||
|
// That is a real cost of styling in the string, and it is recorded here rather
|
||||||
|
// than discovered by the first person to translate the screen.
|
||||||
|
|
||||||
// This screen's buttons are form actions in a single stacked column, not
|
// This screen's buttons are form actions in a single stacked column, not
|
||||||
// chrome beside a photograph — they are given the full `touch-target` height
|
// chrome beside a photograph — they are given the full `touch-target` height
|
||||||
@@ -17,11 +41,21 @@ component FormButton inherits Button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// One folder in the picker. The whole row is the target, not just the text.
|
// One folder in the picker. The whole row is the target, not just the text.
|
||||||
|
//
|
||||||
|
// A `Rectangle` with a `TouchArea` over it is a button as far as the user is
|
||||||
|
// concerned and a decorated box as far as the platform is, so the name reaches
|
||||||
|
// a screen reader — the `Value` below is a `Text` — while the fact that it can
|
||||||
|
// be entered does not. Saying so is what makes the picker navigable rather
|
||||||
|
// than merely readable (NFR-A11Y-2).
|
||||||
component FolderRow inherits Rectangle {
|
component FolderRow inherits Rectangle {
|
||||||
in property <string> label;
|
in property <string> label;
|
||||||
in property <bool> is-parent: false;
|
in property <bool> is-parent: false;
|
||||||
callback clicked();
|
callback clicked();
|
||||||
|
|
||||||
|
accessible-role: button;
|
||||||
|
accessible-label: root.label;
|
||||||
|
accessible-action-default => { root.clicked(); }
|
||||||
|
|
||||||
height: Theme.touch-target;
|
height: Theme.touch-target;
|
||||||
background: touch.has-hover ? Theme.surface-raised : transparent;
|
background: touch.has-hover ? Theme.surface-raised : transparent;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@@ -141,8 +175,8 @@ export component LaunchScreen inherits Rectangle {
|
|||||||
}
|
}
|
||||||
Label {
|
Label {
|
||||||
text: root.signed-in
|
text: root.signed-in
|
||||||
? "Connected"
|
? @tr("Connected")
|
||||||
: "Connect a Nextcloud account, or open a folder";
|
: @tr("Connect a Nextcloud account, or open a folder");
|
||||||
body: true;
|
body: true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,29 +187,36 @@ export component LaunchScreen inherits Rectangle {
|
|||||||
if !root.signed-in && root.login-url == "": VerticalLayout {
|
if !root.signed-in && root.login-url == "": VerticalLayout {
|
||||||
spacing: Theme.gap;
|
spacing: Theme.gap;
|
||||||
|
|
||||||
PanelHeading { text: "SERVER"; }
|
PanelHeading { text: @tr("SERVER"); }
|
||||||
|
|
||||||
server-input := Field {
|
server-input := Field {
|
||||||
|
// The `PanelHeading` above each of these entries names
|
||||||
|
// it on screen and names nothing at all as far as the
|
||||||
|
// accessibility tree is concerned — Slint associates a
|
||||||
|
// heading with the control below it only if something
|
||||||
|
// says so. `Field.label` is that something, so the word
|
||||||
|
// is written twice on purpose.
|
||||||
|
label: @tr("Server address");
|
||||||
text: root.server-url;
|
text: root.server-url;
|
||||||
placeholder: "https://cloud.example.com";
|
placeholder: "https://cloud.example.com";
|
||||||
accepted(url) => { root.sign-in(url); }
|
accepted(url) => { root.sign-in(url); }
|
||||||
}
|
}
|
||||||
|
|
||||||
if !root.can-remember: Caption {
|
if !root.can-remember: Caption {
|
||||||
text: "No system keyring found — you will need to sign in each time.";
|
text: @tr("No system keyring found — you will need to sign in each time.");
|
||||||
warn: true;
|
warn: true;
|
||||||
wrap: word-wrap;
|
wrap: word-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
FormButton {
|
FormButton {
|
||||||
text: root.busy ? "Connecting…" : "Sign in";
|
text: root.busy ? @tr("Connecting…") : @tr("Sign in");
|
||||||
primary: true;
|
primary: true;
|
||||||
enabled: !root.busy && server-input.text != "";
|
enabled: !root.busy && server-input.text != "";
|
||||||
clicked => { root.sign-in(server-input.text); }
|
clicked => { root.sign-in(server-input.text); }
|
||||||
}
|
}
|
||||||
|
|
||||||
Caption {
|
Caption {
|
||||||
text: "Sign-in happens in your browser. DarkRoom never sees your password.";
|
text: @tr("Sign-in happens in your browser. DarkRoom never sees your password.");
|
||||||
wrap: word-wrap;
|
wrap: word-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +234,7 @@ export component LaunchScreen inherits Rectangle {
|
|||||||
background: Theme.rule;
|
background: Theme.rule;
|
||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
}
|
}
|
||||||
Caption { text: "or"; }
|
Caption { text: @tr("or"); }
|
||||||
Rectangle {
|
Rectangle {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: Theme.rule;
|
background: Theme.rule;
|
||||||
@@ -201,14 +242,16 @@ export component LaunchScreen inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PanelHeading { text: "USERNAME"; }
|
PanelHeading { text: @tr("USERNAME"); }
|
||||||
user-input := Field {
|
user-input := Field {
|
||||||
|
label: @tr("Username");
|
||||||
text: "";
|
text: "";
|
||||||
placeholder: "your Nextcloud username";
|
placeholder: @tr("your Nextcloud username");
|
||||||
}
|
}
|
||||||
|
|
||||||
PanelHeading { text: "APP PASSWORD"; }
|
PanelHeading { text: @tr("APP PASSWORD"); }
|
||||||
pass-input := Field {
|
pass-input := Field {
|
||||||
|
label: @tr("App password");
|
||||||
text: "";
|
text: "";
|
||||||
placeholder: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx";
|
placeholder: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx";
|
||||||
secret: true;
|
secret: true;
|
||||||
@@ -218,7 +261,7 @@ export component LaunchScreen inherits Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
FormButton {
|
FormButton {
|
||||||
text: root.busy ? "Connecting…" : "Connect directly";
|
text: root.busy ? @tr("Connecting…") : @tr("Connect directly");
|
||||||
enabled: !root.busy
|
enabled: !root.busy
|
||||||
&& server-input.text != ""
|
&& server-input.text != ""
|
||||||
&& user-input.text != ""
|
&& user-input.text != ""
|
||||||
@@ -233,7 +276,7 @@ export component LaunchScreen inherits Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Caption {
|
Caption {
|
||||||
text: "Create one in Nextcloud under Settings › Security › Devices & sessions. It is device-scoped and can be revoked on its own.";
|
text: @tr("Create one in Nextcloud under Settings › Security › Devices & sessions. It is device-scoped and can be revoked on its own.");
|
||||||
wrap: word-wrap;
|
wrap: word-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +294,7 @@ export component LaunchScreen inherits Rectangle {
|
|||||||
background: Theme.rule;
|
background: Theme.rule;
|
||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
}
|
}
|
||||||
Caption { text: "or"; }
|
Caption { text: @tr("or"); }
|
||||||
Rectangle {
|
Rectangle {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: Theme.rule;
|
background: Theme.rule;
|
||||||
@@ -259,21 +302,22 @@ export component LaunchScreen inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PanelHeading { text: "FOLDER"; }
|
PanelHeading { text: @tr("FOLDER"); }
|
||||||
folder-input := Field {
|
folder-input := Field {
|
||||||
|
label: @tr("Library folder");
|
||||||
text: root.folder-path;
|
text: root.folder-path;
|
||||||
placeholder: "/home/you/Pictures";
|
placeholder: "/home/you/Pictures";
|
||||||
accepted(path) => { root.use-folder(path); }
|
accepted(path) => { root.use-folder(path); }
|
||||||
}
|
}
|
||||||
|
|
||||||
FormButton {
|
FormButton {
|
||||||
text: "Open folder";
|
text: @tr("Open folder");
|
||||||
enabled: !root.busy && folder-input.text != "";
|
enabled: !root.busy && folder-input.text != "";
|
||||||
clicked => { root.use-folder(folder-input.text); }
|
clicked => { root.use-folder(folder-input.text); }
|
||||||
}
|
}
|
||||||
|
|
||||||
Caption {
|
Caption {
|
||||||
text: "Any folder this machine can read: a local disk, a network mount, or one your Nextcloud client already syncs. Nothing is uploaded and no password is needed.";
|
text: @tr("Any folder this machine can read: a local disk, a network mount, or one your Nextcloud client already syncs. Nothing is uploaded and no password is needed.");
|
||||||
wrap: word-wrap;
|
wrap: word-wrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,7 +326,7 @@ export component LaunchScreen inherits Rectangle {
|
|||||||
if root.login-url != "": VerticalLayout {
|
if root.login-url != "": VerticalLayout {
|
||||||
spacing: Theme.gap;
|
spacing: Theme.gap;
|
||||||
|
|
||||||
PanelHeading { text: "APPROVE IN YOUR BROWSER"; }
|
PanelHeading { text: @tr("APPROVE IN YOUR BROWSER"); }
|
||||||
|
|
||||||
Panel {
|
Panel {
|
||||||
Label {
|
Label {
|
||||||
@@ -294,18 +338,18 @@ export component LaunchScreen inherits Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
FormButton {
|
FormButton {
|
||||||
text: "Copy link";
|
text: @tr("Copy link");
|
||||||
clicked => { root.copy-login-url(); }
|
clicked => { root.copy-login-url(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
Caption { text: "Waiting for approval…"; }
|
Caption { text: @tr("Waiting for approval…"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- folder picker ---
|
// --- folder picker ---
|
||||||
if root.signed-in && root.browsing: VerticalLayout {
|
if root.signed-in && root.browsing: VerticalLayout {
|
||||||
spacing: Theme.gap;
|
spacing: Theme.gap;
|
||||||
|
|
||||||
PanelHeading { text: "CHOOSE LIBRARY FOLDER"; }
|
PanelHeading { text: @tr("CHOOSE LIBRARY FOLDER"); }
|
||||||
|
|
||||||
// Current location, so it is always clear what
|
// Current location, so it is always clear what
|
||||||
// "Use this folder" would select.
|
// "Use this folder" would select.
|
||||||
@@ -326,7 +370,7 @@ export component LaunchScreen inherits Rectangle {
|
|||||||
border-color: Theme.rule;
|
border-color: Theme.rule;
|
||||||
|
|
||||||
if root.browse-loading: Caption {
|
if root.browse-loading: Caption {
|
||||||
text: "Loading…";
|
text: @tr("Loading…");
|
||||||
horizontal-alignment: center;
|
horizontal-alignment: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -357,7 +401,7 @@ export component LaunchScreen inherits Rectangle {
|
|||||||
|
|
||||||
if !root.browse-loading && root.browse-entries.length == 0
|
if !root.browse-loading && root.browse-entries.length == 0
|
||||||
&& root.browse-path != "": Caption {
|
&& root.browse-path != "": Caption {
|
||||||
text: "No subfolders here";
|
text: @tr("No subfolders here");
|
||||||
horizontal-alignment: center;
|
horizontal-alignment: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -367,12 +411,12 @@ export component LaunchScreen inherits Rectangle {
|
|||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
spacing: Theme.gap;
|
spacing: Theme.gap;
|
||||||
FormButton {
|
FormButton {
|
||||||
text: "Cancel";
|
text: @tr("Cancel");
|
||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
clicked => { root.browse-cancel(); }
|
clicked => { root.browse-cancel(); }
|
||||||
}
|
}
|
||||||
FormButton {
|
FormButton {
|
||||||
text: "Use this folder";
|
text: @tr("Use this folder");
|
||||||
primary: true;
|
primary: true;
|
||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
clicked => { root.browse-confirm(); }
|
clicked => { root.browse-confirm(); }
|
||||||
@@ -384,22 +428,22 @@ export component LaunchScreen inherits Rectangle {
|
|||||||
if root.signed-in && !root.browsing: VerticalLayout {
|
if root.signed-in && !root.browsing: VerticalLayout {
|
||||||
spacing: Theme.gap;
|
spacing: Theme.gap;
|
||||||
|
|
||||||
PanelHeading { text: "ACCOUNT"; }
|
PanelHeading { text: @tr("ACCOUNT"); }
|
||||||
Value { text: root.account; }
|
Value { text: root.account; }
|
||||||
|
|
||||||
Rectangle { height: Theme.gap-sm; }
|
Rectangle { height: Theme.gap-sm; }
|
||||||
|
|
||||||
PanelHeading { text: "LIBRARY FOLDER"; }
|
PanelHeading { text: @tr("LIBRARY FOLDER"); }
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
spacing: Theme.gap;
|
spacing: Theme.gap;
|
||||||
Value {
|
Value {
|
||||||
text: root.library-root == "" ? "(not chosen)" : root.library-root;
|
text: root.library-root == "" ? @tr("(not chosen)") : root.library-root;
|
||||||
placeholder: root.library-root == "";
|
placeholder: root.library-root == "";
|
||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
overflow: elide;
|
overflow: elide;
|
||||||
}
|
}
|
||||||
FormButton {
|
FormButton {
|
||||||
text: "Choose…";
|
text: @tr("Choose…");
|
||||||
width: 110px;
|
width: 110px;
|
||||||
clicked => { root.choose-folder(); }
|
clicked => { root.choose-folder(); }
|
||||||
}
|
}
|
||||||
@@ -407,7 +451,7 @@ export component LaunchScreen inherits Rectangle {
|
|||||||
|
|
||||||
Rectangle { height: Theme.gap-sm; }
|
Rectangle { height: Theme.gap-sm; }
|
||||||
|
|
||||||
PanelHeading { text: "SCAN FOR"; }
|
PanelHeading { text: @tr("SCAN FOR"); }
|
||||||
|
|
||||||
for label[i] in root.format-labels: Check {
|
for label[i] in root.format-labels: Check {
|
||||||
label: label;
|
label: label;
|
||||||
@@ -418,13 +462,13 @@ export component LaunchScreen inherits Rectangle {
|
|||||||
Rectangle { height: Theme.gap; }
|
Rectangle { height: Theme.gap; }
|
||||||
|
|
||||||
FormButton {
|
FormButton {
|
||||||
text: root.busy ? "Scanning…" : "Open library";
|
text: root.busy ? @tr("Scanning…") : @tr("Open library");
|
||||||
primary: true;
|
primary: true;
|
||||||
enabled: !root.busy && root.library-root != "";
|
enabled: !root.busy && root.library-root != "";
|
||||||
clicked => { root.open-library(); }
|
clicked => { root.open-library(); }
|
||||||
}
|
}
|
||||||
FormButton {
|
FormButton {
|
||||||
text: "Sign out";
|
text: @tr("Sign out");
|
||||||
clicked => { root.sign-out(); }
|
clicked => { root.sign-out(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+141
-9
@@ -649,6 +649,7 @@ export component PhotoRoll inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: NFR-A11Y-3
|
||||||
// A row of five stars, readable at a glance and clickable to set a rating.
|
// A row of five stars, readable at a glance and clickable to set a rating.
|
||||||
//
|
//
|
||||||
// **Filled versus empty carries the meaning, not colour.** NFR-A11Y-3 forbids
|
// **Filled versus empty carries the meaning, not colour.** NFR-A11Y-3 forbids
|
||||||
@@ -796,6 +797,7 @@ export component StarStrip inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: NFR-A11Y-3
|
||||||
// The pick/reject mark.
|
// The pick/reject mark.
|
||||||
//
|
//
|
||||||
// A shape rather than a colour, for the same NFR-A11Y-3 reason as the stars:
|
// A shape rather than a colour, for the same NFR-A11Y-3 reason as the stars:
|
||||||
@@ -1203,6 +1205,40 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
// keys: Escape
|
// keys: Escape
|
||||||
in property <bool> select-mode: false;
|
in property <bool> select-mode: false;
|
||||||
callback toggle-select-mode();
|
callback toggle-select-mode();
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||||
|
/// The photograph a press has held long enough to pick up, or `-1`.
|
||||||
|
///
|
||||||
|
/// **The visible half of a gesture that was folklore.** A finger on a cell
|
||||||
|
/// is ambiguous — it may be starting a scroll or taking hold of a
|
||||||
|
/// photograph — and Slint resolves that by giving the `Flickable` the first
|
||||||
|
/// half-second: any press that travels more than a few pixels vertically
|
||||||
|
/// inside it becomes a scroll, and the drag never begins. Only a fast
|
||||||
|
/// sideways flick, or waiting the half-second out, ever picked a
|
||||||
|
/// photograph up, and nothing on the screen said so. The user's account of
|
||||||
|
/// it was that dragging "sometimes works".
|
||||||
|
///
|
||||||
|
/// So the wait is given a mark. The same hold that turns on selection mode
|
||||||
|
/// sets this, a ring opens outward around the cell, and from that moment
|
||||||
|
/// the drag is the only thing the finger can be doing — the grid below is
|
||||||
|
/// no longer `interactive`, so there is no scroll left to lose to. The cue
|
||||||
|
/// can only ever arrive *after* the ambiguity has passed, which is the
|
||||||
|
/// honest direction: once the ring is open, dragging works.
|
||||||
|
///
|
||||||
|
/// A row of the loaded window, like every other row here. It is cleared
|
||||||
|
/// when the press ends and when a drag finishes, and the grid cannot
|
||||||
|
/// scroll while it is set, so it cannot outlive the window it indexes.
|
||||||
|
// GESTURE: Pick a photograph up to drag it
|
||||||
|
// where: Library grid
|
||||||
|
// touch: Press and hold it until a ring opens around it, then drag
|
||||||
|
// pointer: Drag it
|
||||||
|
// why: A finger on a photograph might be starting a scroll, and for
|
||||||
|
// the first half-second the grid assumes it is. Holding says
|
||||||
|
// otherwise, and the ring is the grid saying it heard — from
|
||||||
|
// there the drag cannot be lost to a scroll. A mouse never
|
||||||
|
// waits: the cursor is precise enough that a sideways drag is
|
||||||
|
// unambiguous from the first pixel.
|
||||||
|
in-out property <int> held-row: -1;
|
||||||
/// TRACES: FR-CAT-5
|
/// TRACES: FR-CAT-5
|
||||||
// --- reordering a manual collection (FR-CAT-7) --------------------------
|
// --- reordering a manual collection (FR-CAT-7) --------------------------
|
||||||
//
|
//
|
||||||
@@ -1285,15 +1321,29 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
/// selected. The name arrives from the sheet below rather than being
|
/// selected. The name arrives from the sheet below rather than being
|
||||||
/// invented by Rust and corrected afterwards — see `naming`.
|
/// invented by Rust and corrected afterwards — see `naming`.
|
||||||
callback collection-from-selection(string);
|
callback collection-from-selection(string);
|
||||||
/// The drag payload: the selected image ids, wrapped by Rust. Called when a
|
/// **What arms the drag, not what it carries.**
|
||||||
/// drag starts, so it always reflects the selection as it is at that moment.
|
///
|
||||||
|
/// `DragArea` refuses to begin a drag while its `data` is empty, and it
|
||||||
|
/// asks on every pointer event — including the first one, long before
|
||||||
|
/// anything is being dragged. A binding that calls a callback is evaluated
|
||||||
|
/// once and cached, because there is nothing for Slint to invalidate it on,
|
||||||
|
/// so whatever this answers the first time a finger touches a cell is what
|
||||||
|
/// that cell's `DragArea` believes for the rest of its life.
|
||||||
|
///
|
||||||
|
/// It is therefore *not* the payload the drop reads, whatever it looks
|
||||||
|
/// like: what travels is `dragging` in `collections_ui`, recorded by
|
||||||
|
/// `drag-started` and read back by `dropped-on`. See the Rust side for why
|
||||||
|
/// this has to answer something non-empty unconditionally.
|
||||||
pure callback drag-payload() -> data-transfer;
|
pure callback drag-payload() -> data-transfer;
|
||||||
/// What travels under the cursor: the dragged thumbnail, or a fanned stack
|
/// What travels under the cursor: the dragged thumbnail, or a fanned stack
|
||||||
/// of them where several are being carried. Composited in Rust, because
|
/// of them where several are being carried. Composited in Rust, because
|
||||||
/// Slint accepts one bitmap here and cannot draw a pile of images into it.
|
/// Slint accepts one bitmap here and cannot draw a pile of images into it.
|
||||||
in property <image> drag-image;
|
in property <image> drag-image;
|
||||||
/// A drag began on this cell. Lets Rust promote an unselected cell to the
|
/// A drag began on this cell.
|
||||||
/// selection before the payload is read.
|
///
|
||||||
|
/// This, not `drag-payload`, is where what the drag carries is decided: it
|
||||||
|
/// fires with the selection as it stands at the moment the drag starts, and
|
||||||
|
/// lets Rust promote an unselected cell into the selection first.
|
||||||
callback drag-started(int);
|
callback drag-started(int);
|
||||||
/// The drag ended — dropped or cancelled. Clears the transient UI state.
|
/// The drag ended — dropped or cancelled. Clears the transient UI state.
|
||||||
callback drag-finished();
|
callback drag-finished();
|
||||||
@@ -2516,11 +2566,27 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
|
|
||||||
// --- the grid -----------------------------------------------------
|
// --- the grid -----------------------------------------------------
|
||||||
//
|
//
|
||||||
// `interactive` stays true: `DragArea` and `Flickable` arbitrate
|
// **Scrolls until a photograph has been picked up, and not after.**
|
||||||
// properly, so dragging a cell drags the cell and dragging the
|
//
|
||||||
// background still flicks the grid. (This is the part a hand-rolled
|
// `DragArea` and `Flickable` do arbitrate, but not evenly: the
|
||||||
// TouchArea gesture could not do — see the drag comments above.)
|
// Flickable claims any press that travels more than eight pixels
|
||||||
|
// along its own axis within half a second of landing, and it holds
|
||||||
|
// that claim until the finger lifts. That is right for the ordinary
|
||||||
|
// case — a finger that moves is almost always scrolling — and it is
|
||||||
|
// why dragging the background still flicks the grid.
|
||||||
|
//
|
||||||
|
// It is wrong once the user has said otherwise. `held-row` is that
|
||||||
|
// saying: a press that has stayed put long enough to be a pick-up,
|
||||||
|
// marked on the cell so the user can see it. From there this stops
|
||||||
|
// being interactive and the drag has nothing left to lose to.
|
||||||
|
//
|
||||||
|
// Today the hold outlasts the Flickable's window anyway, so this
|
||||||
|
// mostly makes an accident into a guarantee — the arbitration stops
|
||||||
|
// depending on two constants in different crates staying in the
|
||||||
|
// order they happen to be in. The wheel is unaffected: `interactive`
|
||||||
|
// does not gate it.
|
||||||
if root.total > 0: grid-scroll := Flickable {
|
if root.total > 0: grid-scroll := Flickable {
|
||||||
|
interactive: root.held-row < 0;
|
||||||
// Ctrl+wheel resizes the cells; a plain wheel is declined and
|
// Ctrl+wheel resizes the cells; a plain wheel is declined and
|
||||||
// falls through to the Flickable's own scrolling. Two jobs on
|
// falls through to the Flickable's own scrolling. Two jobs on
|
||||||
// one gesture, distinguished by the modifier — the convention
|
// one gesture, distinguished by the modifier — the convention
|
||||||
@@ -2740,6 +2806,7 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
// is what a join table means, and it is why the modifier-free
|
// is what a join table means, and it is why the modifier-free
|
||||||
// gesture must not be `move`.
|
// gesture must not be `move`.
|
||||||
allow-copy: true;
|
allow-copy: true;
|
||||||
|
// Not the payload — the arming. See `drag-payload`.
|
||||||
data: root.drag-payload();
|
data: root.drag-payload();
|
||||||
// What travels under the cursor is the photograph itself — and
|
// What travels under the cursor is the photograph itself — and
|
||||||
// where several are being dragged, a stack of them. Composited
|
// where several are being dragged, a stack of them. Composited
|
||||||
@@ -2815,7 +2882,19 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
// photograph, which is exactly the wrong feedback for a
|
// photograph, which is exactly the wrong feedback for a
|
||||||
// gesture whose whole job is to say "this one". One
|
// gesture whose whole job is to say "this one". One
|
||||||
// treatment, drawn inside, in one place.
|
// treatment, drawn inside, in one place.
|
||||||
border-width: cell-touch.has-hover ? 1px : 0px;
|
//
|
||||||
|
// **Not drawn at all once there has been a finger.** A
|
||||||
|
// hand has no hover to give, so on a tablet this ring can
|
||||||
|
// only ever be wrong — and it is wrong in the worst way,
|
||||||
|
// because it is the selection ring's own colour a pixel
|
||||||
|
// thinner. `has-hover` is not reliably cleared on touch:
|
||||||
|
// a release normally brings an `Exit` with it, but the one
|
||||||
|
// that ends a pinch does not, so every pinch to resize the
|
||||||
|
// thumbnails left a ring around whichever cell a finger
|
||||||
|
// happened to have started on. The grid then showed boxes
|
||||||
|
// around photographs that were not selected, with no way
|
||||||
|
// to tell them from ones that were. See `touched`.
|
||||||
|
border-width: cell-touch.has-hover && !root.touched ? 1px : 0px;
|
||||||
border-color: Theme.selected-ring;
|
border-color: Theme.selected-ring;
|
||||||
clip: true;
|
clip: true;
|
||||||
|
|
||||||
@@ -3055,6 +3134,8 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
root.cell-clicked(i);
|
root.cell-clicked(i);
|
||||||
}
|
}
|
||||||
self.click-pending = false;
|
self.click-pending = false;
|
||||||
|
// Down again, whether or not it was ever up.
|
||||||
|
root.held-row = -1;
|
||||||
root.cell-press-ended();
|
root.cell-press-ended();
|
||||||
}
|
}
|
||||||
// `cancel` is the important ending: the Flickable
|
// `cancel` is the important ending: the Flickable
|
||||||
@@ -3063,6 +3144,12 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
// would come to rest as a long press and select it.
|
// would come to rest as a long press and select it.
|
||||||
if (ev.kind == PointerEventKind.cancel) {
|
if (ev.kind == PointerEventKind.cancel) {
|
||||||
self.click-pending = false;
|
self.click-pending = false;
|
||||||
|
// Including the cancel a starting drag sends:
|
||||||
|
// by then the `DragArea` has the gesture and
|
||||||
|
// `lifted` is the mark that matters, so there
|
||||||
|
// is no scroll left to lose and nothing to
|
||||||
|
// keep the ring open for.
|
||||||
|
root.held-row = -1;
|
||||||
root.cell-press-ended();
|
root.cell-press-ended();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3236,6 +3323,51 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
border-radius: 1.5px;
|
border-radius: 1.5px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **The photograph is in your hand now.**
|
||||||
|
//
|
||||||
|
// A ring that opens outward around the cell a press has held
|
||||||
|
// long enough to pick up (see `held-row`), so the wait the
|
||||||
|
// gesture needs has something to end in. Without it the user
|
||||||
|
// is holding a finger on glass with no way to know whether
|
||||||
|
// anything has happened — which is what made dragging feel
|
||||||
|
// like a coin toss.
|
||||||
|
//
|
||||||
|
// **Drawn after the cells, not on one.** Cells are one `for`,
|
||||||
|
// and z-order inside a `for` is the loop order — a cell that
|
||||||
|
// grew past its bounds would stand over its left and top
|
||||||
|
// neighbours and be cut off by its right and bottom ones,
|
||||||
|
// which reads as a rendering fault rather than as a lift. One
|
||||||
|
// element after the loop is above every cell by construction,
|
||||||
|
// and there is only ever one photograph in the hand.
|
||||||
|
//
|
||||||
|
// `visible` rather than `if`, so it has somewhere to animate
|
||||||
|
// *from*: an `if` builds the ring at its final size and it
|
||||||
|
// would appear rather than open.
|
||||||
|
//
|
||||||
|
// `Theme.active` and 3px, deliberately unlike the selection
|
||||||
|
// ring inside the cell — two marks that meant different things
|
||||||
|
// in one colour is the mistake the hover ring made.
|
||||||
|
Rectangle {
|
||||||
|
property <length> pitch: root.cell-size + Theme.gap;
|
||||||
|
property <length> reach: root.held-row >= 0 ? 5px : 0px;
|
||||||
|
|
||||||
|
visible: root.held-row >= 0;
|
||||||
|
x: Theme.gap
|
||||||
|
+ mod(root.held-row + root.offset, root.columns) * self.pitch
|
||||||
|
- self.reach;
|
||||||
|
y: Theme.gap
|
||||||
|
+ floor((root.held-row + root.offset) / root.columns) * self.pitch
|
||||||
|
- self.reach;
|
||||||
|
width: root.cell-size + 2 * self.reach;
|
||||||
|
height: root.cell-size + 2 * self.reach;
|
||||||
|
animate x, y, width, height { duration: 120ms; easing: ease-out; }
|
||||||
|
|
||||||
|
background: transparent;
|
||||||
|
border-width: 3px;
|
||||||
|
border-color: Theme.active;
|
||||||
|
border-radius: Theme.radius;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// TRACES: FR-CULL-3
|
// TRACES: FR-CULL-3 | NFR-A11Y-3
|
||||||
// The focus-peaking switch, and the two choices it exposes.
|
// The focus-peaking switch, and the two choices it exposes.
|
||||||
//
|
//
|
||||||
// **An instrument, not an operation**, exactly as the histogram above it is:
|
// **An instrument, not an operation**, exactly as the histogram above it is:
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// The question asked when the catalog turns out to be damaged.
|
||||||
|
//
|
||||||
|
// # Why this is a modal, when almost nothing else here is
|
||||||
|
//
|
||||||
|
// The house rule in this interface is to put the consequence in the button's
|
||||||
|
// label rather than to raise a dialogue — "Export 40", "Empty trash · 128" —
|
||||||
|
// and a genuine modal is kept for the two cases where the answer commits
|
||||||
|
// gigabytes. This is the third case, and it earns it for a different reason:
|
||||||
|
// there is nothing behind it to interact with. The grid cannot be drawn, the
|
||||||
|
// scan must not run (it would write into the damage), and every control in the
|
||||||
|
// window is about a library that cannot be read. A banner over an empty grid
|
||||||
|
// would be a question the user could scroll away from and then wonder why
|
||||||
|
// nothing worked.
|
||||||
|
//
|
||||||
|
// # Why the backdrop does not dismiss it
|
||||||
|
//
|
||||||
|
// Every other overlay here closes on a tap outside, and this one deliberately
|
||||||
|
// does not. A stray tap that loses the two offers leaves the application in a
|
||||||
|
// state with no way forward and no obvious way back to the question. There is
|
||||||
|
// a "Leave it for now" button instead, which says what it does.
|
||||||
|
//
|
||||||
|
// # Why the destructive answer is not the primary one
|
||||||
|
//
|
||||||
|
// A restore keeps the user's collections; a rebuild cannot, because a manual
|
||||||
|
// collection is a set of images the user assembled by hand and nothing in the
|
||||||
|
// filesystem records it (docs/catalog.md §8.1). So the two answers are not
|
||||||
|
// interchangeable, the difference is stated in the button rather than in a
|
||||||
|
// second dialogue after it, and the rebuild is the plain button even when it
|
||||||
|
// is the only one available.
|
||||||
|
|
||||||
|
import { Theme } from "theme.slint";
|
||||||
|
import { Button } from "widgets.slint";
|
||||||
|
|
||||||
|
export component RecoveryPrompt inherits Rectangle {
|
||||||
|
/// What went wrong, in the user's terms. Empty closes the prompt — one
|
||||||
|
/// source for "is this open", rather than a bool that can disagree with
|
||||||
|
/// the words beside it.
|
||||||
|
in property <string> title;
|
||||||
|
/// What is safe and what is not, which is the part that determines whether
|
||||||
|
/// the next minute is frightening.
|
||||||
|
in property <string> detail;
|
||||||
|
/// What SQLite actually said, kept because a bug report needs it and
|
||||||
|
/// because a diagnosis the user can read is worth more than a reassurance
|
||||||
|
/// they cannot check.
|
||||||
|
in property <string> diagnosis;
|
||||||
|
/// The restore offer, naming the backup's date. Empty when there is no
|
||||||
|
/// backup to restore from, which is the case a fresh install is in.
|
||||||
|
in property <string> restore-label;
|
||||||
|
in property <bool> can-restore: false;
|
||||||
|
/// The rebuild offer, naming what it costs — a full rescan, and the
|
||||||
|
/// collections it cannot bring back.
|
||||||
|
in property <string> rebuild-label;
|
||||||
|
/// Set while a restore or rebuild is running, so neither can be started
|
||||||
|
/// twice against the same file.
|
||||||
|
in property <bool> busy: false;
|
||||||
|
|
||||||
|
callback restore();
|
||||||
|
callback rebuild();
|
||||||
|
callback dismiss();
|
||||||
|
|
||||||
|
visible: root.title != "";
|
||||||
|
background: #000000E0;
|
||||||
|
|
||||||
|
// Swallows everything that misses the card, and answers nothing. See the
|
||||||
|
// header: losing this by a stray tap leaves nowhere to go.
|
||||||
|
TouchArea { }
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: min(460px, parent.width - 2 * Theme.gap-lg);
|
||||||
|
height: min(card.preferred-height, parent.height - 2 * Theme.gap-lg);
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
background: Theme.surface;
|
||||||
|
border-radius: Theme.radius;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.rule;
|
||||||
|
|
||||||
|
TouchArea { }
|
||||||
|
|
||||||
|
card := VerticalLayout {
|
||||||
|
padding: Theme.gap-lg;
|
||||||
|
spacing: Theme.gap;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Recover library";
|
||||||
|
color: Theme.ink-faint;
|
||||||
|
font-size: Theme.text-sm;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.title;
|
||||||
|
color: Theme.ink;
|
||||||
|
font-size: Theme.text-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.detail;
|
||||||
|
color: Theme.ink-dim;
|
||||||
|
font-size: Theme.text;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapped rather than elided: this is the one line a bug report
|
||||||
|
// needs verbatim, and a truncated SQLite message is no message.
|
||||||
|
Text {
|
||||||
|
text: root.diagnosis;
|
||||||
|
color: Theme.ink-faint;
|
||||||
|
font-size: Theme.text-sm;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 1px; background: Theme.rule; }
|
||||||
|
|
||||||
|
// Stacked, not a row: each label carries what its answer costs —
|
||||||
|
// a date, a count of photographs — and three of those side by side
|
||||||
|
// elide away exactly the part that lets the user choose.
|
||||||
|
if root.can-restore: Button {
|
||||||
|
text: root.busy ? "Working…" : root.restore-label;
|
||||||
|
primary: true;
|
||||||
|
enabled: !root.busy;
|
||||||
|
clicked => { root.restore(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: root.busy ? "Working…" : root.rebuild-label;
|
||||||
|
// Primary only when it is the only answer there is. A rebuild
|
||||||
|
// discards collections, so it does not get the emphasis while
|
||||||
|
// a restore that keeps them is on the table.
|
||||||
|
primary: !root.can-restore;
|
||||||
|
enabled: !root.busy;
|
||||||
|
clicked => { root.rebuild(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Leave it for now";
|
||||||
|
enabled: !root.busy;
|
||||||
|
clicked => { root.dismiss(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -121,6 +121,26 @@ export component ToolRail inherits Rectangle {
|
|||||||
root.picked(entry.on ? ViewMode.photo : tool.mode);
|
root.picked(entry.on ? ViewMode.photo : tool.mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **The word below is drawn; this is what makes it a control.**
|
||||||
|
// Without these the rail reaches a screen reader as four pieces of
|
||||||
|
// static text — the labels get through, because a `Text` announces
|
||||||
|
// itself, and nothing says any of them can be pressed. That is the
|
||||||
|
// develop view's primary navigation reduced to a caption.
|
||||||
|
//
|
||||||
|
// `checkable` unconditionally, unlike `Button`'s: a rail entry is
|
||||||
|
// always a held-or-not state, so an unheld one should say "not
|
||||||
|
// pressed" rather than pass for an ordinary button. The action
|
||||||
|
// repeats the click handler rather than calling it, because a
|
||||||
|
// `TouchArea`'s `clicked` is raised by the pointer and cannot be
|
||||||
|
// raised from here.
|
||||||
|
accessible-role: button;
|
||||||
|
accessible-label: tool.label;
|
||||||
|
accessible-checkable: true;
|
||||||
|
accessible-checked: entry.on;
|
||||||
|
accessible-action-default => {
|
||||||
|
root.picked(entry.on ? ViewMode.photo : tool.mode);
|
||||||
|
}
|
||||||
|
|
||||||
// The lit tile, and the only marker there is. Inset from the
|
// The lit tile, and the only marker there is. Inset from the
|
||||||
// rail's edges so the run of four reads as four things rather than
|
// rail's edges so the run of four reads as four things rather than
|
||||||
// as one striped column.
|
// as one striped column.
|
||||||
|
|||||||
@@ -13,6 +13,21 @@
|
|||||||
// `surface` is; only this file says what a *panel heading* is — and until it
|
// `surface` is; only this file says what a *panel heading* is — and until it
|
||||||
// did, four screens each re-derived one, which is exactly how a single accent
|
// did, four screens each re-derived one, which is exactly how a single accent
|
||||||
// colour reached forty call sites with no single place to change it.
|
// colour reached forty call sites with no single place to change it.
|
||||||
|
//
|
||||||
|
// **The same argument decides where accessibility lives** (NFR-A11Y-2). What
|
||||||
|
// AT-SPI and TalkBack are handed — a role, a name, a value, and an action they
|
||||||
|
// can invoke — is a property of *what a control is*, not of where it happens
|
||||||
|
// to be used, so it is declared once here and at the call site only where the
|
||||||
|
// call site knows something the component cannot. A button annotated in forty
|
||||||
|
// places is a button unnamed in thirty-nine of them, which is the failure this
|
||||||
|
// file was written to stop, one layer down.
|
||||||
|
//
|
||||||
|
// Two Slint rules shape what that can look like. `accessible-role` must be a
|
||||||
|
// *constant* — a ternary over a runtime property is a compile error — so a
|
||||||
|
// component that would need two roles is two components. And every other
|
||||||
|
// `accessible-*` property is rejected unless a role is set beside it or on the
|
||||||
|
// element it inherits from; that inheritance is what lets a call site add
|
||||||
|
// `accessible-checkable` to a `Button` whose role was set here.
|
||||||
|
|
||||||
import { Theme } from "theme.slint";
|
import { Theme } from "theme.slint";
|
||||||
import { Icon } from "icons.slint";
|
import { Icon } from "icons.slint";
|
||||||
@@ -42,10 +57,31 @@ export component Button inherits Rectangle {
|
|||||||
/// Sustained state — a toggle that is currently on, not a press. The same
|
/// Sustained state — a toggle that is currently on, not a press. The same
|
||||||
/// meaning [`IconButton`] gives it, so a labelled toggle and an icon
|
/// meaning [`IconButton`] gives it, so a labelled toggle and an icon
|
||||||
/// toggle read alike.
|
/// toggle read alike.
|
||||||
|
///
|
||||||
|
/// **Deliberately not exposed to assistive technology from here.** Only
|
||||||
|
/// the call site knows whether a button that is currently *not* active is
|
||||||
|
/// a toggle that is off or an ordinary button that has no such state, and
|
||||||
|
/// announcing every button in the application as an unpressed toggle is
|
||||||
|
/// worse than announcing none of them. A call site that means a toggle
|
||||||
|
/// says so — `accessible-checkable: true; accessible-checked: <state>;` —
|
||||||
|
/// which Slint permits because the role below is inherited.
|
||||||
in property <bool> active: false;
|
in property <bool> active: false;
|
||||||
|
|
||||||
callback clicked();
|
callback clicked();
|
||||||
|
|
||||||
|
accessible-role: button;
|
||||||
|
accessible-label: root.text;
|
||||||
|
accessible-enabled: root.enabled;
|
||||||
|
// The action a screen reader invokes, and it goes around the TouchArea
|
||||||
|
// rather than through it — so the `enabled` gate the TouchArea applies to
|
||||||
|
// a pointer has to be applied again here, or a disabled button would be
|
||||||
|
// pressable by exactly the users who cannot see that it is greyed out.
|
||||||
|
accessible-action-default => {
|
||||||
|
if (root.enabled) {
|
||||||
|
root.clicked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
height: Theme.control-height;
|
height: Theme.control-height;
|
||||||
// A minimum rather than a fixed width: callers that set `width` or hand
|
// A minimum rather than a fixed width: callers that set `width` or hand
|
||||||
// this to a stretching layout still win, and a long label is not clipped.
|
// this to a stretching layout still win, and a long label is not clipped.
|
||||||
@@ -108,12 +144,40 @@ export component Button inherits Rectangle {
|
|||||||
export component IconButton inherits Rectangle {
|
export component IconButton inherits Rectangle {
|
||||||
/// A name from the [`Icon`] vocabulary.
|
/// A name from the [`Icon`] vocabulary.
|
||||||
in property <string> icon;
|
in property <string> icon;
|
||||||
|
/// What the drawing means, in words.
|
||||||
|
///
|
||||||
|
/// A `Button` gets its accessible name for nothing, out of the text it was
|
||||||
|
/// already drawing. This control draws no text at all, so the name has to
|
||||||
|
/// be given — and an icon button without one is not a degraded experience
|
||||||
|
/// for a screen-reader user, it is an unusable one.
|
||||||
|
///
|
||||||
|
/// Left empty it falls back to the icon's own name, which is a bad label
|
||||||
|
/// ("chevron-right") and still a better answer than silence. That is the
|
||||||
|
/// bargain `labels::resolve` already strikes for a key nobody has
|
||||||
|
/// catalogued, and it is struck here for the same reason: a control added
|
||||||
|
/// today should be reachable before someone has written its word.
|
||||||
|
in property <string> label;
|
||||||
in property <bool> enabled: true;
|
in property <bool> enabled: true;
|
||||||
/// Sustained state — a toggle that is currently on, not a press.
|
/// Sustained state — a toggle that is currently on, not a press.
|
||||||
|
///
|
||||||
|
/// Not announced from here, for the reason [`Button`]'s copy of this
|
||||||
|
/// property gives: the component cannot tell an off toggle from a button
|
||||||
|
/// with no state, so the call site that means a toggle sets
|
||||||
|
/// `accessible-checkable: true` and `accessible-checked` beside its
|
||||||
|
/// `active`.
|
||||||
in property <bool> active: false;
|
in property <bool> active: false;
|
||||||
|
|
||||||
callback clicked();
|
callback clicked();
|
||||||
|
|
||||||
|
accessible-role: button;
|
||||||
|
accessible-label: root.label != "" ? root.label : root.icon;
|
||||||
|
accessible-enabled: root.enabled;
|
||||||
|
accessible-action-default => {
|
||||||
|
if (root.enabled) {
|
||||||
|
root.clicked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
width: Theme.control-height;
|
width: Theme.control-height;
|
||||||
height: Theme.control-height;
|
height: Theme.control-height;
|
||||||
horizontal-stretch: 0;
|
horizontal-stretch: 0;
|
||||||
@@ -178,6 +242,21 @@ export component FilterChip inherits Rectangle {
|
|||||||
|
|
||||||
callback clicked();
|
callback clicked();
|
||||||
|
|
||||||
|
// Unlike [`Button`], this one *can* say it is a toggle without asking the
|
||||||
|
// call site, because being one is the whole of what distinguishes it —
|
||||||
|
// "it is *state*, not an action", two paragraphs up. So `checkable` is
|
||||||
|
// unconditional and an inactive chip announces as unpressed rather than
|
||||||
|
// as an ordinary button.
|
||||||
|
//
|
||||||
|
// The count is not folded into the name. It is drawn as a `Text`, which
|
||||||
|
// Slint already exposes as its own node, so a reader reaches it by moving
|
||||||
|
// one step further rather than by hearing a bare number glued to a word.
|
||||||
|
accessible-role: button;
|
||||||
|
accessible-label: root.label;
|
||||||
|
accessible-checkable: true;
|
||||||
|
accessible-checked: root.active;
|
||||||
|
accessible-action-default => { root.clicked(); }
|
||||||
|
|
||||||
height: Theme.control-height - 4px;
|
height: Theme.control-height - 4px;
|
||||||
// A floor on a content-sized chip, expressed as one property: Slint rejects
|
// A floor on a content-sized chip, expressed as one property: Slint rejects
|
||||||
// `width` and `min-width` together, and the floor is what keeps a chip
|
// `width` and `min-width` together, and the floor is what keeps a chip
|
||||||
@@ -304,6 +383,15 @@ export component Section inherits Rectangle {
|
|||||||
/// Whether this section's contents can be reset at all.
|
/// Whether this section's contents can be reset at all.
|
||||||
in property <bool> has-reset: true;
|
in property <bool> has-reset: true;
|
||||||
|
|
||||||
|
accessible-role: groupbox;
|
||||||
|
accessible-label: root.title;
|
||||||
|
accessible-expandable: true;
|
||||||
|
accessible-expanded: root.expanded;
|
||||||
|
accessible-action-expand => {
|
||||||
|
root.expanded = !root.expanded;
|
||||||
|
root.toggled(root.expanded);
|
||||||
|
}
|
||||||
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
// Own height comes from the layout below, so a collapsed section shrinks
|
// Own height comes from the layout below, so a collapsed section shrinks
|
||||||
// to its header.
|
// to its header.
|
||||||
@@ -372,6 +460,22 @@ export component Section inherits Rectangle {
|
|||||||
Rectangle {
|
Rectangle {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
|
|
||||||
|
// The reset is drawn only under the pointer, which
|
||||||
|
// ui-navigation.md D-N2 rules out as the *only* route to
|
||||||
|
// a control. Announcing it whenever it is live gives a
|
||||||
|
// screen reader the route the ink withholds — so this is
|
||||||
|
// not a translation of the visual affordance so much as
|
||||||
|
// the honest version of it, and the gate is `has-reset &&
|
||||||
|
// modified` rather than the hover the `Text` below adds.
|
||||||
|
accessible-role: button;
|
||||||
|
accessible-label: "Reset";
|
||||||
|
accessible-enabled: root.has-reset && root.modified;
|
||||||
|
accessible-action-default => {
|
||||||
|
if (root.has-reset && root.modified) {
|
||||||
|
root.op-reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
reset-touch := TouchArea {
|
reset-touch := TouchArea {
|
||||||
// Sits after `header-touch` in the tree, so it takes
|
// Sits after `header-touch` in the tree, so it takes
|
||||||
// the press first and the section does not toggle out
|
// the press first and the section does not toggle out
|
||||||
@@ -598,6 +702,20 @@ export component Panel inherits Rectangle {
|
|||||||
// token is for.
|
// token is for.
|
||||||
export component Field inherits Rectangle {
|
export component Field inherits Rectangle {
|
||||||
in-out property <string> text;
|
in-out property <string> text;
|
||||||
|
/// What this entry is for, in words.
|
||||||
|
///
|
||||||
|
/// The visible caption belongs to whatever row wraps the field — `TextRow`
|
||||||
|
/// draws one above, the launch screen draws one beside — and none of those
|
||||||
|
/// is a thing Slint associates with the entry on its own. So the name is
|
||||||
|
/// carried a second time, here, where the accessibility tree can attach it
|
||||||
|
/// to the control the user is actually typing into.
|
||||||
|
///
|
||||||
|
/// That second copy is not redundant even where the caption renders. It is
|
||||||
|
/// the *only* copy where the caption does not: `TextRow`'s label draws
|
||||||
|
/// behind its own field on the settings page and has done since 0.9.0, so
|
||||||
|
/// a sighted user reading that page today has less to go on than a screen
|
||||||
|
/// reader does.
|
||||||
|
in property <string> label;
|
||||||
in property <string> placeholder;
|
in property <string> placeholder;
|
||||||
/// Masks the entry, for a credential that should not be readable over the
|
/// Masks the entry, for a credential that should not be readable over the
|
||||||
/// user's shoulder. The placeholder still shows while the field is empty.
|
/// user's shoulder. The placeholder still shows while the field is empty.
|
||||||
@@ -655,6 +773,13 @@ export component Field inherits Rectangle {
|
|||||||
input := TextInput {
|
input := TextInput {
|
||||||
text <=> root.text;
|
text <=> root.text;
|
||||||
edited => { root.edited(self.text); }
|
edited => { root.edited(self.text); }
|
||||||
|
// Slint gives a TextInput its role, its value, its enabled state and
|
||||||
|
// its set-value action for free; the name and the placeholder are the
|
||||||
|
// two it cannot guess. They go on the entry rather than on the box
|
||||||
|
// around it so there is one node in the tree and not a nameless
|
||||||
|
// rectangle wrapping a nameless input.
|
||||||
|
accessible-label: root.label;
|
||||||
|
accessible-placeholder-text: root.placeholder;
|
||||||
color: Theme.ink;
|
color: Theme.ink;
|
||||||
font-size: Theme.text;
|
font-size: Theme.text;
|
||||||
vertical-alignment: center;
|
vertical-alignment: center;
|
||||||
@@ -677,6 +802,12 @@ export component Field inherits Rectangle {
|
|||||||
x: Theme.gap;
|
x: Theme.gap;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
visible: input.text == "";
|
visible: input.text == "";
|
||||||
|
// Drawn text, not content. The same words already reach the tree as
|
||||||
|
// the entry's `accessible-placeholder-text`, where a reader can
|
||||||
|
// announce them as a prompt rather than as a value the field holds —
|
||||||
|
// which is what a second text node beside an empty entry would look
|
||||||
|
// like. `lineedit-base.slint` in Slint's own widgets does exactly this.
|
||||||
|
accessible-role: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,6 +824,22 @@ export component Field inherits Rectangle {
|
|||||||
export component ProgressBar inherits Rectangle {
|
export component ProgressBar inherits Rectangle {
|
||||||
in property <float> fraction: 0;
|
in property <float> fraction: 0;
|
||||||
in property <bool> indeterminate: false;
|
in property <bool> indeterminate: false;
|
||||||
|
/// What is progressing. A bar with no name announces as "progress
|
||||||
|
/// indicator, 40%", which says how far along an unnamed something is.
|
||||||
|
in property <string> label;
|
||||||
|
|
||||||
|
// An indeterminate bar reports no value at all rather than 0%. It has one
|
||||||
|
// — the sweep — but it is not a position, and a reader that announced 0%
|
||||||
|
// for a directory walk that is half done would be stating a falsehood in
|
||||||
|
// the one place the interface was careful not to (see the two modes
|
||||||
|
// above). Silence is the honest answer to "how far".
|
||||||
|
accessible-role: progress-indicator;
|
||||||
|
accessible-label: root.label;
|
||||||
|
accessible-value: root.indeterminate
|
||||||
|
? ""
|
||||||
|
: Math.round(clamp(root.fraction, 0, 1) * 100) + "%";
|
||||||
|
accessible-value-minimum: 0;
|
||||||
|
accessible-value-maximum: 100;
|
||||||
|
|
||||||
height: 3px;
|
height: 3px;
|
||||||
background: Theme.rule;
|
background: Theme.rule;
|
||||||
|
|||||||
Reference in New Issue
Block a user