Compare commits

...
4 Commits
Author SHA1 Message Date
dtourolle fcccc2c2e0 Release 0.19.2
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m29s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 1m4s
Build and test / Android (aarch64) (push) Successful in 31m8s
Build and test / android-image (push) Successful in 2s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / Desktop (Linux) (push) Successful in 50m33s
Build and test / windows-image (push) Successful in 3s
🐳 Windows image / Build and push (push) Successful in 2s
Build and test / Layer separation (push) Successful in 33s
Build and test / Windows (x86_64, cross) (push) Successful in 36m27s
Build and test / Publish the release (push) Successful in 59s
2026-09-29 21:31:58 -04:00
dtourolle 1bc04870c3 Let a crop be trimmed from one edge
Four bar handles at the midpoints of the sides, each moving only its
own side along the axis across it. The overlay reports an edge as 0.5
on the axis it does not move, so the anchor Rust takes is the middle
of the far side.

Under a ratio lock the dragged axis leads. with_aspect grew the short
axis onto the ratio, which for an edge pulled inward made the untouched
axis the leader and pushed the edge straight back out.
2026-09-29 21:31:27 -04:00
dtourolle 4da2ec39b3 Fit the photograph clear of the roll and the floating row while composing
A crop handle straddles the picture's edge. With the photograph fitted
to the whole canvas, the bottom handles sat in the photo roll's band,
whose swipe handler takes every press inside it, and the bottom-left
one under the "Done Composing" row, which is declared after the overlay
and whose buttons press 44px tall. Both were drawn and could not be
grabbed: on a laptop-shaped window, one or the other for nearly any
photograph.

While composing, the canvas box is inset 16px from its clip and stops
above the band, the row's margin and the row's hit height. The band is
measured once, on the canvas, and the row reads it from there.
2026-09-29 21:31:27 -04:00
dtourolle 9558b77759 Say how to build and install from source, not only how to run it
The README gave cargo run and nothing else. A binary run from target/
finds no models and, in a release build, no manual, because both are
looked up only where an install puts them. Spell out the Arch package,
a /usr/local install mirroring the PKGBUILD, the Windows installer
cross-build and where it lands, and the Android APK.
2026-09-29 21:29:17 -04:00
10 changed files with 355 additions and 145 deletions
Generated
+25 -25
View File
@@ -1265,7 +1265,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]] [[package]]
name = "darkroom-android" name = "darkroom-android"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"dr-plat", "dr-plat",
@@ -1278,7 +1278,7 @@ dependencies = [
[[package]] [[package]]
name = "darkroom-desktop" name = "darkroom-desktop"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dr-plat", "dr-plat",
@@ -1454,7 +1454,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]] [[package]]
name = "dr-bench" name = "dr-bench"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dr-catalog", "dr-catalog",
@@ -1471,7 +1471,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-catalog" name = "dr-catalog"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"dr-face", "dr-face",
"dr-plat", "dr-plat",
@@ -1486,7 +1486,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-decode" name = "dr-decode"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"env_logger", "env_logger",
@@ -1500,7 +1500,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-export" name = "dr-export"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"dr-decode", "dr-decode",
"dr-gpu", "dr-gpu",
@@ -1519,7 +1519,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-face" name = "dr-face"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"dr-inference-engine", "dr-inference-engine",
"env_logger", "env_logger",
@@ -1532,7 +1532,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-film" name = "dr-film"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@@ -1541,7 +1541,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-gpu" name = "dr-gpu"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"dr-decode", "dr-decode",
@@ -1559,7 +1559,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-inference-engine" name = "dr-inference-engine"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"env_logger", "env_logger",
"libloading", "libloading",
@@ -1574,7 +1574,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-ingest" name = "dr-ingest"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"dr-plat", "dr-plat",
"dr-types", "dr-types",
@@ -1586,7 +1586,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-lens" name = "dr-lens"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"lensfun", "lensfun",
"log", "log",
@@ -1594,7 +1594,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-pano" name = "dr-pano"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"dr-decode", "dr-decode",
"dr-inference-engine", "dr-inference-engine",
@@ -1608,7 +1608,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-pipeline" name = "dr-pipeline"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"log", "log",
@@ -1617,7 +1617,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-plat" name = "dr-plat"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"android-native-keyring-store", "android-native-keyring-store",
"dr-types", "dr-types",
@@ -1633,7 +1633,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-preset-xmp" name = "dr-preset-xmp"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"dr-pipeline", "dr-pipeline",
"log", "log",
@@ -1643,7 +1643,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-segment" name = "dr-segment"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"dr-inference-engine", "dr-inference-engine",
"env_logger", "env_logger",
@@ -1656,7 +1656,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync" name = "dr-sync"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-plat", "dr-plat",
@@ -1670,7 +1670,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync-folder" name = "dr-sync-folder"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-sync", "dr-sync",
@@ -1682,7 +1682,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync-nextcloud" name = "dr-sync-nextcloud"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-decode", "dr-decode",
@@ -1704,7 +1704,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-thumbs" name = "dr-thumbs"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"jpeg-encoder", "jpeg-encoder",
@@ -1716,7 +1716,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-types" name = "dr-types"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -1725,7 +1725,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-ui" name = "dr-ui"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -1773,7 +1773,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-xmp" name = "dr-xmp"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"log", "log",
@@ -7107,7 +7107,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]] [[package]]
name = "traceability" name = "traceability"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"proc-macro2", "proc-macro2",
+1 -1
View File
@@ -32,7 +32,7 @@ members = [
exclude = ["third_party"] exclude = ["third_party"]
[workspace.package] [workspace.package]
version = "0.19.1" version = "0.19.2"
edition = "2021" edition = "2021"
rust-version = "1.92" rust-version = "1.92"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
+103 -6
View File
@@ -83,28 +83,125 @@ texture directly — no readback between the GPU and the screen.
| Windows | `DarkRoom-<version>-x86_64-setup.exe`, cross-built by CI ([windows.md](docs/dev/windows.md)) | Verified under Wine only; unsigned | | Windows | `DarkRoom-<version>-x86_64-setup.exe`, cross-built by CI ([windows.md](docs/dev/windows.md)) | Verified under Wine only; unsigned |
| Flatpak | [`packaging/flatpak/`](packaging/flatpak/) | Manifest in tree; folders are chosen through the portal, but no Flatpak has been built to prove it | | Flatpak | [`packaging/flatpak/`](packaging/flatpak/) | Manifest in tree; folders are chosen through the portal, but no Flatpak has been built to prove it |
Or build it. Git LFS is required for the model weights, and the toolchain ## Building from source
pins itself to 1.92.0:
**Before anything.** Git LFS holds the model weights and the manual's
pictures; a clone without it has ~130-byte pointers in their place, and every
packager below refuses to ship one. The Rust toolchain pins itself to 1.92.0
through `rust-toolchain.toml`, so rustup is all you install. Slint needs a few
system headers, and the app needs a Vulkan driver at runtime:
```bash ```bash
git clone https://gitea.tourolle.paris/dtourolle/DarkRoom.git && cd DarkRoom
git lfs install && git lfs pull git lfs install && git lfs pull
# Debian / Ubuntu
sudo apt-get install pkg-config libfontconfig1-dev libxkbcommon-dev libvulkan1
# Arch
sudo pacman -S --needed pkgconf fontconfig libxkbcommon vulkan-icd-loader
```
**To try it** from the checkout, without installing anything:
```bash
cargo run --release -p darkroom-desktop cargo run --release -p darkroom-desktop
``` ```
Android, through the containerised toolchain ([docker/android](docker/android/README.md)): This is for development. The binary under `target/` finds no face, scene or
panorama-fill models, and a release build does not find the manual either:
it looks for all of them in the system data directories an install creates
(`$XDG_DATA_DIRS/darkroom`, by default `/usr/local/share/darkroom` and
`/usr/share/darkroom`), never in the checkout. Those features show as
unavailable until it is installed.
### Linux: build and install
**On Arch**, build a package from the checkout and install it with pacman,
so it can be upgraded and removed like any other:
```bash ```bash
./docker/android/build.sh cargo ndk -t arm64-v8a build --release cd packaging && makepkg -si
``` ```
[CONTRIBUTING.md](CONTRIBUTING.md) has the system packages, the four **Elsewhere**, build the release binary and install it under `/usr/local`
by hand. These are the same files, in the same places, as the Arch package
([`packaging/PKGBUILD`](packaging/PKGBUILD)'s `package()` is the reference):
```bash
cargo build --release --locked -p darkroom-desktop
# -> target/release/darkroom-desktop
P=/usr/local
sudo install -Dm755 target/release/darkroom-desktop $P/bin/darkroom-desktop
# The models: faces and eye state, scene categories, panorama border fill
sudo install -d $P/share/darkroom/models
sudo install -m644 models/face/*.onnx models/scene/* models/inpaint/*.onnx \
$P/share/darkroom/models/
# The offline manual the Help menu opens
sudo install -Dm644 docs/manual/index.html $P/share/darkroom/manual/index.html
sudo install -Dm644 -t $P/share/darkroom/manual/media docs/manual/media/*
# Launcher entry, icon and software-centre description
sudo install -Dm644 packaging/paris.tourolle.darkroom.desktop \
$P/share/applications/paris.tourolle.darkroom.desktop
sudo install -Dm644 ui/dr-ui/ui/app-icon.png \
$P/share/icons/hicolor/256x256/apps/paris.tourolle.darkroom.png
sudo install -Dm644 packaging/paris.tourolle.darkroom.metainfo.xml \
$P/share/metainfo/paris.tourolle.darkroom.metainfo.xml
```
Then run `darkroom-desktop`, or open it from the application menu. To
uninstall, remove those files and `/usr/local/share/darkroom`. Your catalog,
settings and thumbnails live in `darkroom/` under your own XDG data, config
and cache directories (`~/.local/share`, `~/.config`, `~/.cache`) and are
not touched by either.
Optional at runtime: `gnome-keyring` or `kwallet` to remember Nextcloud
credentials, and an ONNX Runtime in `/usr/lib` (CPU, or ROCm on an AMD GPU) to
run the models on every core rather than on the built-in engine.
### Windows: build the installer
The `.exe` is cross-built from Linux in a container (podman or docker), with
no Windows machine involved. Two steps — the executable, then the NSIS
installer that carries it with its models and manual:
```bash
./docker/windows/build.sh cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
./docker/windows/build.sh docker/windows/package.sh
```
Both land in the container's cache on the host, `~/.cache/darkroom-windows/target/`:
the bare executable under `x86_64-pc-windows-gnu/release/darkroom-desktop.exe`,
the installer under `installer/DarkRoom-<version>-x86_64-setup.exe`.
Copy that to the Windows machine and run it — it installs per user, needs no
administrator rights, and adds an uninstaller. Run on its own, the bare
`.exe` looks for `models\` and `manual\` beside itself, so use the installer.
[docker/windows](docker/windows/README.md) has the details.
### Android: build the APK
Also containerised ([docker/android](docker/android/README.md)). This
builds, packages and debug-signs the APK, and with `--install` puts it on a
device connected over adb:
```bash
./docker/android/package.sh --install
```
A debug-signed APK cannot replace one installed from a release; uninstall
that first.
[CONTRIBUTING.md](CONTRIBUTING.md) has the four
commands CI runs against what you send, and the shortest useful commands CI runs against what you send, and the shortest useful
contribution — a develop operation is one YAML file, and it arrives with its contribution — a develop operation is one YAML file, and it arrives with its
controls, its place in the chain and its tests. controls, its place in the chain and its tests.
## Where it stands ## Where it stands
**0.19.1**, thirty tagged releases in. 193 numbered requirements in **0.19.2**, thirty-one tagged releases in. 193 numbered requirements in
scope, 85% of them claimed by code and [traced to it](docs/dev/traceability.md); scope, 85% of them claimed by code and [traced to it](docs/dev/traceability.md);
the rest are written down rather than merely absent. the rest are written down rather than merely absent.
+53 -4
View File
@@ -260,8 +260,9 @@ impl CropRect {
/// ///
/// `anchor` is the point of the rect that stays put, in the rect's own /// `anchor` is the point of the rect that stays put, in the rect's own
/// `0..1` coordinates: `(1.0, 1.0)` while the top-left handle is dragged, /// `0..1` coordinates: `(1.0, 1.0)` while the top-left handle is dragged,
/// so the far corner is the one that does not move, and `(0.5, 0.5)` when /// so the far corner is the one that does not move, `(0.0, 0.5)` while
/// a ratio is chosen and the composition should stay where it is. /// the right-hand edge is dragged, and `(0.5, 0.5)` when a ratio is
/// chosen and the composition should stay where it is.
/// ///
/// **The rect grows onto the ratio rather than shrinking onto it.** The /// **The rect grows onto the ratio rather than shrinking onto it.** The
/// axis that is short is extended; the long one is never trimmed. Fitting /// axis that is short is extended; the long one is never trimmed. Fitting
@@ -269,6 +270,7 @@ impl CropRect {
/// along one axis alone would be immediately clamped back by the other, /// along one axis alone would be immediately clamped back by the other,
/// and the handle would simply refuse to move. The result is then scaled /// and the handle would simply refuse to move. The result is then scaled
/// down, both axes together, only as far as the frame's edge demands. /// down, both axes together, only as far as the frame's edge demands.
/// The exception is an edge: see the note in the body.
pub fn with_aspect(self, frame_w: u32, frame_h: u32, ratio: f32, anchor: (f32, f32)) -> Self { pub fn with_aspect(self, frame_w: u32, frame_h: u32, ratio: f32, anchor: (f32, f32)) -> Self {
let rect = self.normalised(); let rect = self.normalised();
let ratio = finite(ratio, 0.0); let ratio = finite(ratio, 0.0);
@@ -286,8 +288,19 @@ impl CropRect {
let px = rect.x + ax * rect.width; let px = rect.x + ax * rect.width;
let py = rect.y + ay * rect.height; let py = rect.y + ay * rect.height;
let mut w = rect.width.max(rect.height * r); // An anchor in the middle of one side is an *edge* being dragged, and
let mut h = w / r; // then the axis across that edge leads: it is the only one the user
// moved. Growing the short axis instead would take the other side
// for the leader whenever the edge went inward, and the edge would be
// pushed straight back out — a handle that only ever grows the crop.
let (mut w, mut h) = if ax == 0.5 && ay != 0.5 {
(rect.height * r, rect.height)
} else if ay == 0.5 && ax != 0.5 {
(rect.width, rect.width / r)
} else {
let w = rect.width.max(rect.height * r);
(w, w / r)
};
// Scaled to fit, never clamped to fit: clamping one axis against the // Scaled to fit, never clamped to fit: clamping one axis against the
// frame would break the very ratio this exists to hold. // frame would break the very ratio this exists to hold.
@@ -2255,6 +2268,42 @@ mod tests {
assert!((c.y - start.y).abs() < 1e-5, "{c:?}"); assert!((c.y - start.y).abs() < 1e-5, "{c:?}");
} }
#[test]
fn a_locked_edge_leads_and_the_far_side_stays_put() {
// An edge dragged inward under a lock must narrow the crop. With the
// short axis leading, the untouched height would win and push the
// edge straight back out.
let start = CropRect {
x: 0.2,
y: 0.2,
width: 0.4,
height: 0.6,
};
// Right edge held, dragged in: the left side and the vertical
// centre stay, the width is what was asked for.
let c = start.with_aspect(4000, 4000, 1.0, (0.0, 0.5));
assert!((c.x - start.x).abs() < 1e-5, "{c:?}");
assert!((c.width - start.width).abs() < 1e-5, "{c:?}");
assert!((c.height - start.width).abs() < 1e-5, "{c:?}");
assert!(
(c.y + c.height / 2.0 - (start.y + start.height / 2.0)).abs() < 1e-5,
"{c:?}"
);
// Top edge held: the bottom and the horizontal centre stay, the
// height is what was asked for.
let c = start.with_aspect(4000, 4000, 1.0, (0.5, 1.0));
assert!(
(c.y + c.height - (start.y + start.height)).abs() < 1e-5,
"{c:?}"
);
assert!((c.width - c.height).abs() < 1e-5, "{c:?}");
assert!(
(c.x + c.width / 2.0 - (start.x + start.width / 2.0)).abs() < 1e-5,
"{c:?}"
);
}
#[test] #[test]
fn a_locked_rect_grows_onto_the_ratio_rather_than_shrinking_onto_it() { fn a_locked_rect_grows_onto_the_ratio_rather_than_shrinking_onto_it() {
// Shrinking to fit makes a one-axis drag do nothing at all: the other // Shrinking to fit makes a one-axis drag do nothing at all: the other
File diff suppressed because one or more lines are too long
+22 -22
View File
@@ -39,7 +39,7 @@ The list is longer than it is tall, so a way to walk it that cannot be lost to t
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between. Past 1:1 the pixels are shown as they are, square and unsmoothed; below it, filtered. Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between. Past 1:1 the pixels are shown as they are, square and unsmoothed; below it, filtered.
<sub>`ui/dr-ui/ui/app.slint:1982`</sub> <sub>`ui/dr-ui/ui/app.slint:2025`</sub>
### Move a magnified photograph about ### Move a magnified photograph about
@@ -50,7 +50,7 @@ Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as m
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture. Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
<sub>`ui/dr-ui/ui/app.slint:2078`</sub> <sub>`ui/dr-ui/ui/app.slint:2121`</sub>
### Paint a mask by hand ### Paint a mask by hand
@@ -60,7 +60,7 @@ Only once there is something outside the viewport to reach, which is why the cur
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make. A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
<sub>`ui/dr-ui/ui/app.slint:2169`</sub> <sub>`ui/dr-ui/ui/app.slint:2212`</sub>
### Open this list ### Open this list
@@ -70,7 +70,7 @@ A model's mask stops inside a shoulder and leaks into the hair, and no single ed
Most of the keys are develop's, and a reference that could only be opened from the grid had to be looked up before opening the photograph they were wanted for. Most of the keys are develop's, and a reference that could only be opened from the grid had to be looked up before opening the photograph they were wanted for.
<sub>`ui/dr-ui/ui/app.slint:2395`</sub> <sub>`ui/dr-ui/ui/app.slint:2438`</sub>
### Take back the last change ### Take back the last change
@@ -81,7 +81,7 @@ Most of the keys are develop's, and a reference that could only be opened from t
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does. A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
<sub>`ui/dr-ui/ui/app.slint:2425`</sub> <sub>`ui/dr-ui/ui/app.slint:2468`</sub>
### Do it again after taking it back ### Do it again after taking it back
@@ -90,7 +90,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
- **Keyboard** — `Ctrl+Shift+Z`, or `Ctrl+Y` - **Keyboard** — `Ctrl+Shift+Z`, or `Ctrl+Y`
- **See it** — [in the manual](manual/README.md#history-snapshots-presets) - **See it** — [in the manual](manual/README.md#history-snapshots-presets)
<sub>`ui/dr-ui/ui/app.slint:2439`</sub> <sub>`ui/dr-ui/ui/app.slint:2482`</sub>
### Remove a repair ### Remove a repair
@@ -98,7 +98,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
- **Pointer** — Click it, then Delete Repair - **Pointer** — Click it, then Delete Repair
- **Keyboard** — `Delete` or `Backspace`, while repairing - **Keyboard** — `Delete` or `Backspace`, while repairing
<sub>`ui/dr-ui/ui/app.slint:2459`</sub> <sub>`ui/dr-ui/ui/app.slint:2502`</sub>
### Copy the settings from this photograph ### Copy the settings from this photograph
@@ -109,7 +109,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
The button is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way. The button is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
<sub>`ui/dr-ui/ui/app.slint:2478`</sub> <sub>`ui/dr-ui/ui/app.slint:2521`</sub>
### Paste the settings onto this photograph ### Paste the settings onto this photograph
@@ -120,7 +120,7 @@ The button is the copy that has to work: a tablet has no modifier key to hold an
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope. The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
<sub>`ui/dr-ui/ui/app.slint:2491`</sub> <sub>`ui/dr-ui/ui/app.slint:2534`</sub>
### Choose which kinds of edit a copy carries ### Choose which kinds of edit a copy carries
@@ -131,7 +131,7 @@ The button names what would be pasted — "3 adjustments", and whether the crop
Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving each frame's crop and rotation alone, and that is a choice to make at the moment of copying. Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving each frame's crop and rotation alone, and that is a choice to make at the moment of copying.
<sub>`ui/dr-ui/ui/app.slint:2509`</sub> <sub>`ui/dr-ui/ui/app.slint:2552`</sub>
### Export this photograph as the last one was ### Export this photograph as the last one was
@@ -142,7 +142,7 @@ Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving e
Every export runs on the defaults in Settings, so "as the last one was" is what the button already does. The chord is Lightroom's and darktable's, kept so hands that learned it there need not learn it again. Every export runs on the defaults in Settings, so "as the last one was" is what the button already does. The chord is Lightroom's and darktable's, kept so hands that learned it there need not learn it again.
<sub>`ui/dr-ui/ui/app.slint:2534`</sub> <sub>`ui/dr-ui/ui/app.slint:2577`</sub>
### Choose how to export, then export ### Choose how to export, then export
@@ -153,7 +153,7 @@ Every export runs on the defaults in Settings, so "as the last one was" is what
The export sheet is the export defaults alone with an Export button. What is chosen there is kept, so it is also what the next Ctrl+Shift+E uses. The export sheet is the export defaults alone with an Export button. What is chosen there is kept, so it is also what the next Ctrl+Shift+E uses.
<sub>`ui/dr-ui/ui/app.slint:2547`</sub> <sub>`ui/dr-ui/ui/app.slint:2590`</sub>
### Keep a crop that leaves a mask outside ### Keep a crop that leaves a mask outside
@@ -161,7 +161,7 @@ The export sheet is the export defaults alone with an Export button. What is cho
- **Pointer** — Press "Keep crop" on the notice, or "Undo crop" to take it back - **Pointer** — Press "Keep crop" on the notice, or "Undo crop" to take it back
- **Keyboard** — `Enter` keeps it; `Ctrl+Z` takes the crop back, like any other step - **Keyboard** — `Enter` keeps it; `Ctrl+Z` takes the crop back, like any other step
<sub>`ui/dr-ui/ui/app.slint:2612`</sub> <sub>`ui/dr-ui/ui/app.slint:2655`</sub>
### Go back to the grid ### Go back to the grid
@@ -171,7 +171,7 @@ The export sheet is the export defaults alone with an Export button. What is cho
Lightroom's key for the grid. Escape gets there too, but a step at a time — out of a mode, then out of a zoom — where this goes straight back. Lightroom's key for the grid. Escape gets there too, but a step at a time — out of a mode, then out of a zoom — where this goes straight back.
<sub>`ui/dr-ui/ui/app.slint:2629`</sub> <sub>`ui/dr-ui/ui/app.slint:2672`</sub>
### Nudge the control last moved ### Nudge the control last moved
@@ -181,7 +181,7 @@ Lightroom's key for the grid. Escape gets there too, but a step at a time — ou
Lightroom's keys for the selected slider. There is no focus ring on a slider here, so "selected" is the last one moved — the same control `R` puts back — which covers the framing sliders, perspective included, as well as the adjustments. Lightroom's keys for the selected slider. There is no focus ring on a slider here, so "selected" is the last one moved — the same control `R` puts back — which covers the framing sliders, perspective included, as well as the adjustments.
<sub>`ui/dr-ui/ui/app.slint:2658`</sub> <sub>`ui/dr-ui/ui/app.slint:2701`</sub>
### Change which group of adjustments is on screen ### Change which group of adjustments is on screen
@@ -192,7 +192,7 @@ Lightroom's keys for the selected slider. There is no focus ring on a slider her
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added. The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
<sub>`ui/dr-ui/ui/app.slint:2686`</sub> <sub>`ui/dr-ui/ui/app.slint:2729`</sub>
### Look at the photograph at 1:1 ### Look at the photograph at 1:1
@@ -203,7 +203,7 @@ The groups are whatever the operation set declares itself to be about, so there
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans. From 1:1 on the photograph is drawn as its own pixels, each a hard-edged square, rather than smoothed into a blur. Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans. From 1:1 on the photograph is drawn as its own pixels, each a hard-edged square, rather than smoothed into a blur.
<sub>`ui/dr-ui/ui/app.slint:2722`</sub> <sub>`ui/dr-ui/ui/app.slint:2765`</sub>
### Rate this photograph ### Rate this photograph
@@ -211,7 +211,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
- **Pointer** — Click a star in the top bar - **Pointer** — Click a star in the top bar
- **Keyboard** — `0`–`5` - **Keyboard** — `0`–`5`
<sub>`ui/dr-ui/ui/app.slint:2779`</sub> <sub>`ui/dr-ui/ui/app.slint:2822`</sub>
### Pick or reject this photograph ### Pick or reject this photograph
@@ -221,7 +221,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging here does not move on to the next frame: that belongs to culling, and in develop the photograph in front of you is the one being worked on. The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging here does not move on to the next frame: that belongs to culling, and in develop the photograph in front of you is the one being worked on.
<sub>`ui/dr-ui/ui/app.slint:2785`</sub> <sub>`ui/dr-ui/ui/app.slint:2828`</sub>
### Give this photograph a colour label ### Give this photograph a colour label
@@ -232,7 +232,7 @@ The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging h
The grid's keys, on the photograph that is open, so labelling while stepping through a folder is one hand's work. The bar names the label in words beside its mark. The grid's keys, on the photograph that is open, so labelling while stepping through a folder is one hand's work. The bar names the label in words beside its mark.
<sub>`ui/dr-ui/ui/app.slint:2815`</sub> <sub>`ui/dr-ui/ui/app.slint:2858`</sub>
### Move to the next or previous photograph ### Move to the next or previous photograph
@@ -243,7 +243,7 @@ The grid's keys, on the photograph that is open, so labelling while stepping thr
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing. A and D as well as the arrows, so the left hand steps along the roll while the right stays on the mouse. The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing. A and D as well as the arrows, so the left hand steps along the roll while the right stays on the mouse.
<sub>`ui/dr-ui/ui/app.slint:2840`</sub> <sub>`ui/dr-ui/ui/app.slint:2883`</sub>
### See the photograph before you edited it ### See the photograph before you edited it
@@ -254,7 +254,7 @@ The edit on screen is saved on the way out, so stepping through a folder is as m
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards. Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
<sub>`ui/dr-ui/ui/app.slint:2970`</sub> <sub>`ui/dr-ui/ui/app.slint:3008`</sub>
### Put one control back to its default ### Put one control back to its default
+1 -1
View File
@@ -4,7 +4,7 @@
# makes `makepkg -si` in this directory install what you are actually working # makes `makepkg -si` in this directory install what you are actually working
# on. Swap `source` for a tagged tarball when there is something to release. # on. Swap `source` for a tagged tarball when there is something to release.
pkgname=darkroom pkgname=darkroom
pkgver=0.19.1 pkgver=0.19.2
# Back to 1 with the version: a new pkgver is a new archive name, so there is # Back to 1 with the version: a new pkgver is a new archive name, so there is
# nothing for makepkg to reuse and nothing for a release number to disambiguate. # nothing for makepkg to reuse and nothing for a release number to disambiguate.
pkgrel=1 pkgrel=1
+4 -3
View File
@@ -1042,9 +1042,10 @@ fn wire_zoom_pan_crop(
width, width,
height, height,
}; };
// The overlay reports the corner it is *holding*; the point // The overlay reports the handle it is *holding*; the point
// that must not move is the opposite one. A move reports no // that must not move is the opposite one — the far corner, or
// corner at all, and keeps the shape it already has — there is // for an edge the middle of the far side. A move reports no
// handle at all, and keeps the shape it already has — there is
// nothing to reshape, and reshaping about a centre would drag // nothing to reshape, and reshaping about a centre would drag
// an over-moved rect smaller instead of sliding it along the // an over-moved rect smaller instead of sliding it along the
// edge. // edge.
+55 -17
View File
@@ -1823,8 +1823,10 @@ in property <bool> panel-visible: true;
clip: true; clip: true;
canvas-image := Image { canvas-image := Image {
width: 100%; x: parent.fit-x;
height: 100%; y: parent.fit-y;
width: parent.fit-w;
height: parent.fit-h;
source: root.canvas; source: root.canvas;
image-fit: contain; image-fit: contain;
// TRACES: FR-UI-4 // TRACES: FR-UI-4
@@ -1872,8 +1874,10 @@ in property <bool> panel-visible: true;
// original straight away, and the last draft of the edit // original straight away, and the last draft of the edit
// fades out over it rather than vanishing. // fades out over it rather than vanishing.
previous-frame := Image { previous-frame := Image {
width: 100%; x: canvas-image.x;
height: 100%; y: canvas-image.y;
width: canvas-image.width;
height: canvas-image.height;
source: root.canvas-previous; source: root.canvas-previous;
image-fit: contain; image-fit: contain;
image-rendering: canvas-image.image-rendering; image-rendering: canvas-image.image-rendering;
@@ -1941,15 +1945,54 @@ in property <bool> panel-visible: true;
// The overlay is placed against *this*, not against the whole // The overlay is placed against *this*, not against the whole
// area — otherwise the crop rect drifts off the picture on any // area — otherwise the crop rect drifts off the picture on any
// window whose shape differs from the image's. // window whose shape differs from the image's.
//
// **While composing, the box is inset and stops above the
// photo roll.** A crop handle straddles the picture's edge,
// half of it outside, and a photograph fitted to the full
// box puts that half under this area's clip — and, along
// the bottom, under the roll's band, whose swipe handler
// takes every press inside it (see `PhotoRoll`). The bottom
// corners were drawn there, lit up, and could not be
// grabbed. Fitting the picture clear of both is the only
// fix: the handler has to be where it is, and moving the
// handles inward would leave them off the corner they
// move.
property <length> roll-band:
Library.library-total > 0
? (root.roll-open
? Theme.roll-strip + Theme.roll-reach
: Theme.roll-reach)
: 0px;
// The roll's own travel, so what stands on it rises with
// the strip rather than jumping ahead of it.
animate roll-band { duration: 180ms; easing: ease-out; }
//
// The floating "Done Composing" row stands on that band,
// and is declared after the crop overlay so it takes a
// press first: a photograph filling the width put the
// bottom-left handle under it. So the reserve is the band,
// the row's 12px margin, and the row's hit height — a
// `Button`'s press area is the touch target, not its ink.
// Every other side keeps half a handle and a finger's
// slop clear of this area's clip.
property <length> fit-inset: Develop.cropping ? 16px : 0px;
property <length> fit-below: Develop.cropping
? self.roll-band + 12px + Theme.touch-target
: 0px;
property <length> fit-x: self.fit-inset;
property <length> fit-y: self.fit-inset;
property <length> fit-w: max(1px, self.width - 2 * self.fit-inset);
property <length> fit-h: max(1px, self.height - 2 * self.fit-inset - self.fit-below);
property <float> src-w: root.canvas.width > 0 ? root.canvas.width : 1; property <float> src-w: root.canvas.width > 0 ? root.canvas.width : 1;
property <float> src-h: root.canvas.height > 0 ? root.canvas.height : 1; property <float> src-h: root.canvas.height > 0 ? root.canvas.height : 1;
property <float> box-w: self.width / 1px; property <float> box-w: self.fit-w / 1px;
property <float> box-h: self.height / 1px; property <float> box-h: self.fit-h / 1px;
property <float> fit-scale: min(self.box-w / self.src-w, self.box-h / self.src-h); property <float> fit-scale: min(self.box-w / self.src-w, self.box-h / self.src-h);
property <length> shown-w: self.src-w * self.fit-scale * 1px; property <length> shown-w: self.src-w * self.fit-scale * 1px;
property <length> shown-h: self.src-h * self.fit-scale * 1px; property <length> shown-h: self.src-h * self.fit-scale * 1px;
property <length> shown-x: (self.width - self.shown-w) / 2; property <length> shown-x: self.fit-x + (self.fit-w - self.shown-w) / 2;
property <length> shown-y: (self.height - self.shown-h) / 2; property <length> shown-y: self.fit-y + (self.fit-h - self.shown-h) / 2;
// Empty and error states say what happened rather than // Empty and error states say what happened rather than
// showing a blank canvas. // showing a blank canvas.
@@ -2904,15 +2947,10 @@ in property <bool> panel-visible: true;
// the thing that could not be reached. They stack // the thing that could not be reached. They stack
// instead — the controls ride on top of the roll's // instead — the controls ride on top of the roll's
// band, at whatever height it currently stands. // band, at whatever height it currently stands.
property <length> roll-clear: // The canvas's measure of the band, animated there
Library.library-total > 0 // with the roll's own travel so these rise with the
? (root.roll-open // strip rather than jumping ahead of it.
? Theme.roll-strip + Theme.roll-reach property <length> roll-clear: canvas-area.roll-band;
: Theme.roll-reach)
: 0px;
// The roll's own travel, so they rise with the strip
// rather than jumping ahead of it.
animate roll-clear { duration: 180ms; easing: ease-out; }
x: 12px; x: 12px;
y: parent.height - self.preferred-height - 12px y: parent.height - self.preferred-height - 12px
+59 -34
View File
@@ -37,15 +37,17 @@ export component CropOverlay inherits Rectangle {
in property <float> crop-w: 1.0; in property <float> crop-w: 1.0;
in property <float> crop-h: 1.0; in property <float> crop-h: 1.0;
/// A dragged crop rect — `x`, `y`, `width`, `height`, then the corner the /// A dragged crop rect — `x`, `y`, `width`, `height`, then the handle the
/// drag is holding as a `0`/`1` pair, or `-1, -1` where the whole rect is /// drag is holding as a point of the rect: a `0`/`1` pair for a corner,
/// being moved and its shape must not be touched. /// `0.5` on the axis an edge does not move, or `-1, -1` where the whole
/// rect is being moved and its shape must not be touched.
/// ///
/// The corner is what an aspect lock needs and cannot infer: reshaping a /// The handle is what an aspect lock needs and cannot infer: reshaping a
/// rect onto a ratio has to know which of its corners is nailed down, and /// rect onto a ratio has to know which point of it is nailed down, and
/// only the handle that took the press knows that. It is reported as the /// only the handle that took the press knows that. It is reported as the
/// held corner rather than as the fixed one because that is what is /// held point rather than as the fixed one because that is what is
/// written here — Rust takes the opposite corner. /// written here — Rust takes the opposite one, which for an edge is the
/// middle of the far side.
callback crop-changed(float, float, float, float, float, float); callback crop-changed(float, float, float, float, float, float);
/// TRACES: FR-DEV-17 /// TRACES: FR-DEV-17
@@ -189,24 +191,43 @@ export component CropOverlay inherits Rectangle {
} }
} }
// Corner handles. Each drags one corner while the opposite // Handles: the four edges, then the four corners.
// stays put, which is the only behaviour that lets a crop //
// be shaped rather than merely moved. // A corner drags one corner while the opposite stays put, which is the
for corner in [ // only behaviour that lets a crop be shaped rather than merely moved. An
// edge drags that one side along the axis across it and leaves the other
// three where they are — the way to trim a strip off one side without
// disturbing the corner you had already placed.
//
// Each is a point of the rect in its own `0..1` coordinates; `0.5` on an
// axis is an edge's midpoint and means "this axis does not move". The
// edges come first because among overlapping siblings the last declared
// takes the press, and on a rect dragged small the corners are the ones
// that must stay reachable.
for handle in [
{ hx: 0.5, hy: 0.0 },
{ hx: 0.5, hy: 1.0 },
{ hx: 0.0, hy: 0.5 },
{ hx: 1.0, hy: 0.5 },
{ hx: 0.0, hy: 0.0 }, { hx: 0.0, hy: 0.0 },
{ hx: 1.0, hy: 0.0 }, { hx: 1.0, hy: 0.0 },
{ hx: 0.0, hy: 1.0 }, { hx: 0.0, hy: 1.0 },
{ hx: 1.0, hy: 1.0 }, { hx: 1.0, hy: 1.0 },
]: Rectangle { ]: Rectangle {
property <length> size: 18px; property <bool> across: handle.hx == 0.5;
x: parent.rx + corner.hx * parent.rw - self.size / 2; property <bool> along: handle.hy == 0.5;
y: parent.ry + corner.hy * parent.rh - self.size / 2; // A corner is a square; an edge is a bar lying along its side, with
width: self.size; // a hit area as long as the bar and as deep as a corner's.
height: self.size; property <length> grip: 18px;
property <length> reach: 36px;
width: self.across ? self.reach : self.grip;
height: self.along ? self.reach : self.grip;
x: parent.rx + handle.hx * parent.rw - self.width / 2;
y: parent.ry + handle.hy * parent.rh - self.height / 2;
Rectangle { Rectangle {
width: 12px; width: parent.across ? 24px : (parent.along ? 5px : 12px);
height: 12px; height: parent.along ? 24px : (parent.across ? 5px : 12px);
background: #ffffff; background: #ffffff;
border-radius: 2px; border-radius: 2px;
} }
@@ -214,7 +235,11 @@ export component CropOverlay inherits Rectangle {
TouchArea { TouchArea {
width: 100%; width: 100%;
height: 100%; height: 100%;
mouse-cursor: (corner.hx == corner.hy) mouse-cursor: parent.across
? MouseCursor.ns-resize
: parent.along
? MouseCursor.ew-resize
: (handle.hx == handle.hy)
? MouseCursor.nwse-resize ? MouseCursor.nwse-resize
: MouseCursor.nesw-resize; : MouseCursor.nesw-resize;
@@ -246,27 +271,27 @@ export component CropOverlay inherits Rectangle {
} }
} }
// Movement as a fraction of the frame, live while // Movement as a fraction of the frame, live while the handle is
// the handle is held. // held — and nothing along an axis the handle does not move, so
property <float> dx: self.fraction-x(self.mouse-x) - self.from-x; // an edge dragged at a slant still only moves its own side.
property <float> dy: self.fraction-y(self.mouse-y) - self.from-y; property <float> dx: parent.across ? 0 : self.fraction-x(self.mouse-x) - self.from-x;
property <float> dy: parent.along ? 0 : self.fraction-y(self.mouse-y) - self.from-y;
moved => { moved => {
if (!self.pressed) { if (!self.pressed) {
return; return;
} }
// Dragging a left/top handle moves the origin // Dragging a left/top handle moves the origin and shrinks the
// and shrinks the extent by the same amount; // extent by the same amount; a right/bottom handle moves only
// a right/bottom handle moves only the extent. // the extent. Rust clamps the result, so an over-drag slides
// Rust clamps the result, so an over-drag // rather than inverting.
// slides rather than inverting.
root.crop-changed( root.crop-changed(
corner.hx == 0 ? self.ox + self.dx : self.ox, handle.hx == 0 ? self.ox + self.dx : self.ox,
corner.hy == 0 ? self.oy + self.dy : self.oy, handle.hy == 0 ? self.oy + self.dy : self.oy,
corner.hx == 0 ? self.ow - self.dx : self.ow + self.dx, handle.hx == 0 ? self.ow - self.dx : self.ow + self.dx,
corner.hy == 0 ? self.oh - self.dy : self.oh + self.dy, handle.hy == 0 ? self.oh - self.dy : self.oh + self.dy,
corner.hx, handle.hx,
corner.hy, handle.hy,
); );
} }
} }