Compare commits

...
10 Commits
Author SHA1 Message Date
dtourolle 031315bdb6 Run cargo fmt over the develop shortcuts and the star-range filter
Benchmarks / CPU and I/O (per commit) (push) Successful in 2m8s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 33s
Build and test / Android (aarch64) (push) Successful in 14m21s
Build and test / android-image (push) Successful in 1s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / Desktop (Linux) (push) Successful in 47m57s
Build and test / windows-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / Layer separation (push) Successful in 51s
Build and test / Windows (x86_64, cross) (push) Successful in 34m3s
Build and test / Publish the release (push) Successful in 1m3s
bddf325 and 00c028c went in unformatted, so the Desktop job's
`cargo fmt --check` step failed on master (run 1693) and the release job
that needs it was skipped. Whitespace only.
2026-09-24 20:05:02 -04:00
dtourolle 00c028c8c8 Rate under the pointer, filter a star range, and name Help as help
Benchmarks / CPU and I/O (per commit) (push) Successful in 1m53s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 2m50s
Build and test / Layer separation (push) Successful in 33s
Traceability / Requirement traces (push) Successful in 40s
🐳 Android image / Build and push (push) Successful in 4s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 4s
Build and test / windows-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Successful in 14m19s
Build and test / Windows (x86_64, cross) (push) Successful in 17m50s
Build and test / Publish the release (push) Skipped
Rating keys in the grid follow darktable's rule: with the pointer over a
photograph outside the selection, 0-5, P, X and U judge that photograph
alone; over one inside it, the whole selection, as before; off the grid,
the selection. The hover is cleared when the grid scrolls, so a key after
a wheel turn cannot judge whatever used to be under the pointer.

Holding F and tapping digits filters by stars: one digit for exactly
that many, two for everything between them, F alone to show every
rating again. The filter gains a ceiling to do it (`max_rating`, one
BETWEEN in the query). The place record carries it, and a record from an
older build reads as having none. The star chips light across a capped
range and the bar says "2-3★ only" beside them.

The grid also takes Ctrl+E and Ctrl+Shift+E for the selection, Ctrl+V to
paste onto it and Ctrl+A to select all. The "Gestures" button is now
"Help", its sheet "Controls and shortcuts", and F1 opens it.
2026-09-24 05:11:36 +02:00
dtourolle bddf3250c5 Add Lightroom's export and copy shortcuts to develop
Ctrl+E opens an export sheet: the export defaults on their own, over the
photograph, with an Export button. Ctrl+Shift+E exports straight away on
those defaults. There is no per-export copy of the settings, so what is
chosen in the sheet is saved as it is on the settings page, and the next
Ctrl+Shift+E uses it.

To make that one set of controls in two places, the export options move
out of the settings page into export.slint: an `ExportOptions` global
that Rust writes once, and two panels that read it. The window no longer
forwards forty `settings-*` properties to the page.

Ctrl+Shift+C opens a copy sheet with the edit-kind chips the preset
sheet already uses and a Copy button, which is how a paste leaves each
photograph's crop and rotation alone (Compose off). A and D step along
the roll beside the arrows. While either sheet is up the develop keys
stand down, so A cannot change the photograph behind the form, and
Escape closes it.
2026-09-24 05:11:16 +02:00
dtourolle 41486bd59b Step to the next photograph from the keyboard in a library's develop
The arrow keys and space in develop called `next-image` and
`prev-image`, which walk the files given on the command line. A
photograph opened from a library leaves that list empty, so the keys did
nothing and only a click on the photo roll moved on.

With a library open the step now goes through the roll: it opens the
neighbouring frame exactly as clicking it would, and saves the outgoing
edit the same way.
2026-09-24 05:09:39 +02:00
dtourolle d6d27fb062 Publish a Gitea Release from CI on every v* tag
Benchmarks / CPU and I/O (per commit) (push) Successful in 2m28s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 45m9s
Build and test / Layer separation (push) Successful in 38s
Traceability / Requirement traces (push) Successful in 44s
🐳 Android image / Build and push (push) Successful in 5s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 5s
Build and test / windows-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Successful in 14m25s
Build and test / Windows (x86_64, cross) (push) Successful in 34m30s
Build and test / Publish the release (push) Skipped
Nothing made a release. CI built the APK and the installer on the master
push and kept them as workflow artefacts, the Linux binary was not kept
at all, and most tags went out with no downloads until they were
attached by hand.

build-and-test now also runs on v* tags. On a tag the desktop job keeps
its release binary, and a release job that needs desktop, Android and
Windows collects the three, names them with the version and runs
tools/publish-release.sh. The script titles and describes the release
from the annotated tag's message as the server holds it, writes
SHA256SUMS, and attaches what is not already there, so a re-run after
an interrupted upload finishes the job instead of duplicating it. The
same script is how a release is made or finished by hand.

Tried on v0.14.1, whose release was made by hand with the same files:
it found the release, reported all four files attached, and changed
nothing.
2026-09-24 03:43:42 +02:00
dtourolle 317a2f40bd Release 0.14.1
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m19s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 43m6s
Build and test / Layer separation (push) Successful in 37s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Traceability / Requirement traces (push) Successful in 46s
Build and test / Android (aarch64) (push) Successful in 29m7s
Build and test / Windows (x86_64, cross) (push) Successful in 34m31s
2026-09-23 19:07:22 -04:00
dtourolle 949fe40d5b Pin "Export N" to the right of the library's selection bar
It was the last of a dozen buttons in a row that scrolls sideways once
it outgrows the window, which at a desktop width it does. The button
sat past the right edge and nothing says the row scrolls to a mouse,
so batch export of a selection looked like a feature the library did
not have. The rest of the row still scrolls; Export, which is also the
cancel for a running batch, now sits beside it and is always visible.
2026-09-23 19:05:42 -04:00
dtourolle 2be80d4203 Show "Paste to N" on every selection, disabled until something is copied
It appeared only once settings had been copied this session, so with an
empty clipboard nothing on the selection bar said pasting onto a
selection was possible. It is one of the things a selection can have
done to it, like filing it in a collection, and now sits with them
beside Presets.
2026-09-22 21:37:41 -04:00
dtourolle 9ebaa15099 Move copy, paste and presets into the develop top bar
They sat in the develop column under a "SETTINGS" heading, which read as
application settings, and went away with the panel toggle and in the
mask and spot modes. The strip is where undo already is for the same
reason: these act on the whole edit, not on any one panel.

The paste button still names what it would apply. The TransferPanel
component is gone; the Transfer global and its Rust wiring are
unchanged.
2026-09-22 21:37:31 -04:00
dtourolle aee355fada Make the in-flight claim test wait for the waiter to park
a_second_claim_waits_for_the_first_to_be_released failed on CI: the
second claim came back Some. The waiter thread signalled the main thread
before calling claim, so the main thread could drop the first guard
before the waiter reached the lock. The path was free by then, and the
waiter claimed it outright.

The registry now keeps a test-only count of threads parked in claim,
bumped under the lock just before the condvar wait. The test spins until
that count is one before releasing. The release needs the same lock, so
it can only reach a waiter that is already waiting. Passed 500 runs in a
row.
2026-09-22 21:12:45 -04:00
27 changed files with 1863 additions and 901 deletions
+58
View File
@@ -7,6 +7,10 @@ name: Build and test
on:
push:
branches: [main, master, develop]
# A release tag builds again and publishes what it built (the `release`
# job at the end). The master push of the same commit has usually filled
# the caches, so the second run is the warm one.
tags: ['v*']
pull_request:
branches: [main, master, develop]
@@ -154,6 +158,16 @@ jobs:
- name: Build
run: cargo build --workspace --release
# Only on a release tag: the binary is 150 MB and nothing but the
# release job wants it.
- name: Upload the desktop binary
if: startsWith(github.ref, 'refs/tags/v')
uses: actions/upload-artifact@v3
with:
name: darkroom-desktop-x86_64-linux
path: target/release/darkroom-desktop
if-no-files-found: error
- name: Disk after
if: always()
run: df -h /workspace 2>/dev/null || df -h .
@@ -519,3 +533,47 @@ jobs:
fi
done
exit $FAILED
# A v* tag becomes a Gitea Release carrying the three builds and their
# SHA256SUMS, titled and described by the tag's message. Until this job
# existed every release was made by hand, and most tags never got one.
#
# It needs all three platform jobs, so a tag whose tests fail publishes
# nothing; re-run the failed job and this one follows. The work is
# tools/publish-release.sh, which is also how a release is finished by hand.
release:
if: startsWith(github.ref, 'refs/tags/v')
needs: [desktop, android, windows]
runs-on: linux/amd64
name: Publish the release
container:
image: catthehacker/ubuntu:act-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fetch the builds
uses: actions/download-artifact@v3
with:
path: dist
# Named for the download page, with the version in each name the way
# the hand-made releases had them. The installer already carries its
# version from package.sh.
- name: Publish
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
TAG: ${{ github.ref_name }}
run: |
set -e
V="${TAG#v}"
ls -lR dist
mkdir -p out
cp dist/darkroom-arm64-v8a-apk/darkroom.apk "out/darkroom-${V}-arm64-v8a.apk"
cp dist/darkroom-desktop-x86_64-linux/darkroom-desktop "out/darkroom-desktop-${V}-x86_64-linux"
chmod +x "out/darkroom-desktop-${V}-x86_64-linux"
cp dist/darkroom-windows-x86_64-setup/DarkRoom-${V}-x86_64-setup.exe out/
bash tools/publish-release.sh "$TAG" out/*
Generated
+25 -25
View File
@@ -1221,7 +1221,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "darkroom-android"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"android_logger",
"dr-plat",
@@ -1234,7 +1234,7 @@ dependencies = [
[[package]]
name = "darkroom-desktop"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"anyhow",
"dr-plat",
@@ -1408,7 +1408,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dr-bench"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"anyhow",
"dr-catalog",
@@ -1425,7 +1425,7 @@ dependencies = [
[[package]]
name = "dr-catalog"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"dr-face",
"dr-plat",
@@ -1440,7 +1440,7 @@ dependencies = [
[[package]]
name = "dr-decode"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"dr-types",
"env_logger",
@@ -1454,7 +1454,7 @@ dependencies = [
[[package]]
name = "dr-export"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"dr-decode",
"dr-gpu",
@@ -1473,7 +1473,7 @@ dependencies = [
[[package]]
name = "dr-face"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"dr-inference-engine",
"env_logger",
@@ -1486,7 +1486,7 @@ dependencies = [
[[package]]
name = "dr-film"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"log",
"serde",
@@ -1495,7 +1495,7 @@ dependencies = [
[[package]]
name = "dr-gpu"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"bytemuck",
"dr-decode",
@@ -1513,7 +1513,7 @@ dependencies = [
[[package]]
name = "dr-inference-engine"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"env_logger",
"libloading",
@@ -1528,7 +1528,7 @@ dependencies = [
[[package]]
name = "dr-ingest"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"dr-plat",
"dr-types",
@@ -1540,7 +1540,7 @@ dependencies = [
[[package]]
name = "dr-lens"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"lensfun",
"log",
@@ -1548,7 +1548,7 @@ dependencies = [
[[package]]
name = "dr-pano"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"dr-decode",
"dr-inference-engine",
@@ -1562,7 +1562,7 @@ dependencies = [
[[package]]
name = "dr-pipeline"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"dr-types",
"log",
@@ -1571,7 +1571,7 @@ dependencies = [
[[package]]
name = "dr-plat"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"android-native-keyring-store",
"dr-types",
@@ -1587,7 +1587,7 @@ dependencies = [
[[package]]
name = "dr-preset-xmp"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"dr-pipeline",
"log",
@@ -1597,7 +1597,7 @@ dependencies = [
[[package]]
name = "dr-segment"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"dr-inference-engine",
"env_logger",
@@ -1610,7 +1610,7 @@ dependencies = [
[[package]]
name = "dr-sync"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"async-trait",
"dr-plat",
@@ -1624,7 +1624,7 @@ dependencies = [
[[package]]
name = "dr-sync-folder"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"async-trait",
"dr-sync",
@@ -1636,7 +1636,7 @@ dependencies = [
[[package]]
name = "dr-sync-nextcloud"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"async-trait",
"dr-decode",
@@ -1658,7 +1658,7 @@ dependencies = [
[[package]]
name = "dr-thumbs"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"dr-types",
"jpeg-encoder",
@@ -1670,7 +1670,7 @@ dependencies = [
[[package]]
name = "dr-types"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"serde",
"serde_json",
@@ -1679,7 +1679,7 @@ dependencies = [
[[package]]
name = "dr-ui"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"anyhow",
"async-trait",
@@ -1722,7 +1722,7 @@ dependencies = [
[[package]]
name = "dr-xmp"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"dr-types",
"log",
@@ -7023,7 +7023,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "traceability"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"anyhow",
"serde",
+1 -1
View File
@@ -29,7 +29,7 @@ members = [
]
[workspace.package]
version = "0.14.0"
version = "0.14.1"
edition = "2021"
rust-version = "1.92"
license = "GPL-3.0-or-later"
+1 -1
View File
@@ -76,7 +76,7 @@ controls, its place in the chain and its tests.
## Where it stands
**0.14.0**, twenty-one tagged releases in. 188 numbered requirements in
**0.14.1**, twenty-two tagged releases in. 188 numbered requirements in
scope, 82% of them claimed by code and [traced to it](docs/dev/traceability.md);
the rest are written down rather than merely absent.
+4
View File
@@ -115,6 +115,10 @@ pub enum PlaceScope {
#[serde(default)]
pub struct StoredFilter {
pub min_rating: u8,
/// TRACES: FR-UI-5
/// The top of a star range. A record from before ranges existed has none,
/// which reads as no ceiling — what it meant when it was written.
pub max_rating: Option<u8>,
pub unjudged: bool,
pub flag: Option<FlagState>,
pub local_only: bool,
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -684,7 +684,8 @@ three columns of equal width side by side, each its own scroll:
with them because it is not in their column.
2. **Sliders** — `GroupStrip` above `AdjustPanel`, exactly as in the column.
With a group selected this is one screen of sliders; with All it scrolls.
3. **The mode's panels** — `ComposePanel` and `TransferPanel` in photo mode,
3. **The mode's panels** — `ComposePanel` in photo mode (copy and paste
moved to the top bar, 2026-09-22),
`SpotPanel` in repair, `MaskPanel` in local. Empty otherwise, which is
a signal of its own about which mode the view is in (§1.1).
+94 -35
View File
@@ -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.
41 gestures, in 4 places.
47 gestures, in 4 places.
## Develop
@@ -25,7 +25,7 @@ Sampling a neutral is the first move of the tonal pass — every colour judgemen
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.
<sub>`ui/dr-ui/ui/app.slint:1762`</sub>
<sub>`ui/dr-ui/ui/app.slint:1707`</sub>
### Move a magnified photograph about
@@ -34,7 +34,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.
<sub>`ui/dr-ui/ui/app.slint:1853`</sub>
<sub>`ui/dr-ui/ui/app.slint:1798`</sub>
### Paint a mask by hand
@@ -43,7 +43,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.
<sub>`ui/dr-ui/ui/app.slint:1940`</sub>
<sub>`ui/dr-ui/ui/app.slint:1885`</sub>
### Take back the last change
@@ -53,7 +53,7 @@ A model's mask stops inside a shoulder and leaks into the hair, and no single ed
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:2160`</sub>
<sub>`ui/dr-ui/ui/app.slint:2113`</sub>
### Do it again after taking it back
@@ -61,27 +61,57 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
- **Pointer** — Click it, or press Redo in the History header
- **Keyboard** — Ctrl+Shift+Z
<sub>`ui/dr-ui/ui/app.slint:2173`</sub>
<sub>`ui/dr-ui/ui/app.slint:2126`</sub>
### Copy the settings from this photograph
- **Touch** — Press Copy in the Settings panel
- **Pointer** — Press Copy in the Settings panel
- **Touch** — Press Copy in the top bar
- **Pointer** — Press Copy in the top bar
- **Keyboard** — Ctrl+C
The panel 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:2206`</sub>
<sub>`ui/dr-ui/ui/app.slint:2159`</sub>
### Paste the settings onto this photograph
- **Touch** — Press Paste in the Settings panel
- **Pointer** — Press Paste in the Settings panel
- **Touch** — Press Paste in the top bar
- **Pointer** — Press Paste in the top bar
- **Keyboard** — Ctrl+V
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:2218`</sub>
<sub>`ui/dr-ui/ui/app.slint:2171`</sub>
### Choose which kinds of edit a copy carries
- **Touch** — Open Presets and toggle the kinds
- **Pointer** — Open Presets and toggle the kinds
- **Keyboard** — Ctrl+Shift+C, which offers Copy beside them
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:2188`</sub>
### Export this photograph as the last one was
- **Touch** — Press Export in the top bar
- **Pointer** — Press Export in the top bar
- **Keyboard** — Ctrl+Shift+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.
<sub>`ui/dr-ui/ui/app.slint:2217`</sub>
### Choose how to export, then export
- **Touch** — Open Settings, then Export defaults
- **Pointer** — Open Settings, then Export defaults
- **Keyboard** — Ctrl+E
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:2229`</sub>
### Change which group of adjustments is on screen
@@ -91,7 +121,7 @@ The button names what would be pasted — "3 adjustments", and whether the crop
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:2246`</sub>
<sub>`ui/dr-ui/ui/app.slint:2254`</sub>
### Look at the photograph at 1:1
@@ -101,17 +131,17 @@ 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.
<sub>`ui/dr-ui/ui/app.slint:2281`</sub>
<sub>`ui/dr-ui/ui/app.slint:2289`</sub>
### Move to the next or previous photograph
- **Touch** — Tap a frame in the roll along the foot of the canvas
- **Pointer** — Click a frame in the roll
- **Keyboard** — Right arrow or space for the next, left arrow for the one before
- **Keyboard** — Right arrow, D or space for the next; left arrow or A for the one before
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.
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. Unmodified only: Ctrl+D and Ctrl+A are not this.
<sub>`ui/dr-ui/ui/app.slint:2333`</sub>
<sub>`ui/dr-ui/ui/app.slint:2341`</sub>
### See the photograph before you edited it
@@ -121,7 +151,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.
<sub>`ui/dr-ui/ui/app.slint:2457`</sub>
<sub>`ui/dr-ui/ui/app.slint:2474`</sub>
### Put one control back to its default
@@ -264,7 +294,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.
<sub>`ui/dr-ui/ui/library.slint:1572`</sub>
<sub>`ui/dr-ui/ui/library.slint:1591`</sub>
### Add or remove one photograph
@@ -273,7 +303,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.
<sub>`ui/dr-ui/ui/library.slint:1581`</sub>
<sub>`ui/dr-ui/ui/library.slint:1600`</sub>
### Leave selecting
@@ -281,7 +311,7 @@ While selecting, a tap never opens. That is the whole point of the mode: one mea
- **Pointer** — Press Done in the header
- **Keyboard** — Escape
<sub>`ui/dr-ui/ui/library.slint:1589`</sub>
<sub>`ui/dr-ui/ui/library.slint:1608`</sub>
### Pick a photograph up to drag it
@@ -290,7 +320,7 @@ While selecting, a tap never opens. That is the whole point of the mode: one mea
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:1619`</sub>
<sub>`ui/dr-ui/ui/library.slint:1638`</sub>
### Select a range
@@ -299,7 +329,7 @@ A finger on a photograph might be starting a scroll, and for the first half-seco
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:1684`</sub>
<sub>`ui/dr-ui/ui/library.slint:1703`</sub>
### Take the blinks out of a burst
@@ -308,7 +338,7 @@ This replaced a double tap, which had no visible state and could take forty phot
Face indexing reads each face's eyes. The chip drops frames where the chosen people are caught blinking, and leaves sunglasses and eyes it could not read alone.
<sub>`ui/dr-ui/ui/library.slint:2383`</sub>
<sub>`ui/dr-ui/ui/library.slint:2411`</sub>
### Find photographs with two people in them
@@ -317,7 +347,35 @@ Face indexing reads each face's eyes. The chip drops frames where the chosen peo
"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:2412`</sub>
<sub>`ui/dr-ui/ui/library.slint:2440`</sub>
### Export the selection as the last export was
- **Touch** — Select them, then Export in the selection bar
- **Pointer** — Select them, then Export in the selection bar
- **Keyboard** — Ctrl+Shift+E, or Ctrl+E to see the export settings first
Lightroom's and darktable's chords. Every export runs on the saved defaults, so the plain chord opens them beside an Export button and the shifted one skips straight to exporting.
<sub>`ui/dr-ui/ui/library.slint:2993`</sub>
### Paste copied settings onto the selection
- **Touch** — Select them, then "Paste to N" in the selection bar
- **Pointer** — Select them, then "Paste to N"
- **Keyboard** — Ctrl+V
<sub>`ui/dr-ui/ui/library.slint:3017`</sub>
### Show only photographs with some number of stars
- **Touch** — Tap a star chip in the filter bar
- **Pointer** — Click a star chip in the filter bar
- **Keyboard** — Hold F and tap a digit for exactly that many stars, or two digits for everything between them; tap F alone to show every rating again
The chips say "this many or more". A range with a ceiling — the twos and threes still to be decided — is the keyboard's alone, and the bar says so in words while it holds.
<sub>`ui/dr-ui/ui/library.slint:3039`</sub>
### Resize the thumbnails
@@ -326,7 +384,7 @@ Face indexing reads each face's eyes. The chip drops frames where the chosen peo
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:3072`</sub>
<sub>`ui/dr-ui/ui/library.slint:3245`</sub>
### File photographs in a collection
@@ -335,7 +393,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.
<sub>`ui/dr-ui/ui/library.slint:3269`</sub>
<sub>`ui/dr-ui/ui/library.slint:3442`</sub>
### Open a photograph
@@ -344,17 +402,17 @@ 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.
<sub>`ui/dr-ui/ui/library.slint:3537`</sub>
<sub>`ui/dr-ui/ui/library.slint:3723`</sub>
### Rate a photograph without opening it
- **Touch** — Tap a star on the cell
- **Pointer** — Hover the cell, then click a star
- **Keyboard** — 0 to 5 on the selection
- **Keyboard** — 0 to 5 with the pointer over it, or on the selection
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:3657`</sub>
<sub>`ui/dr-ui/ui/library.slint:3843`</sub>
### Choose the frame a folded burst shows
@@ -363,7 +421,7 @@ A star has to take the press without it also reaching the cell, or every rating
A folded burst draws its earliest frame, which is a fact about the clock and not a judgement about the photograph — nothing in this application ranks a frame (FR-CULL-5). But the point of a burst is that one of the twelve is better than the other eleven, and the photographer is the only one who knows which. So the choice is offered on the frames themselves, while they are open and side by side, which is the one moment the alternatives are on screen to be compared.
<sub>`ui/dr-ui/ui/library.slint:3788`</sub>
<sub>`ui/dr-ui/ui/library.slint:3975`</sub>
### Drop the selection but keep selecting
@@ -372,16 +430,17 @@ A folded burst draws its earliest frame, which is a fact about the clock and not
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:4454`</sub>
<sub>`ui/dr-ui/ui/library.slint:4651`</sub>
### Select everything the grid is showing
- **Touch** — While selecting, press "Select all"
- **Pointer** — While selecting, press "Select all"
- **Keyboard** — Ctrl+A
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:4471`</sub>
<sub>`ui/dr-ui/ui/library.slint:4668`</sub>
### Take photographs out of a collection
@@ -390,4 +449,4 @@ A scoped grid of two hundred frames is two hundred taps otherwise, and "all of t
The badge on a cell says a photograph is filed in three collections and never which. This is the sheet that names them, and the only way out of one the grid is not currently scoped to.
<sub>`ui/dr-ui/ui/library.slint:4595`</sub>
<sub>`ui/dr-ui/ui/library.slint:4792`</sub>
+1 -1
View File
@@ -4,7 +4,7 @@
# 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.
pkgname=darkroom
pkgver=0.14.0
pkgver=0.14.1
# 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.
pkgrel=1
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# Turn a pushed tag into a Gitea Release with the build's files attached.
#
# GITEA_TOKEN=... tools/publish-release.sh v0.14.1 FILE...
#
# The release is named and described by the tag's own message — its first
# line is the title, the rest the notes — so the notes are written once, in
# the annotated tag, and not restated anywhere. SHA256SUMS over the given
# files is written and attached alongside them.
#
# Run by CI's `release` job on every v* tag, and by hand with the same
# arguments when a release has to be made or finished from a workstation.
# It is safe to repeat: an existing release is reused, and a file already
# attached under the same name and size is skipped, so a second run after an
# interrupted upload attaches only what is missing.
#
# Before this existed nothing made a release: CI built the APK and the
# installer on the master push and kept them as workflow artefacts, and
# most tags went out with no downloads at all.
set -euo pipefail
GITEA_URL="${GITEA_URL:-https://gitea.tourolle.paris}"
GITEA_REPO="${GITEA_REPO:-dtourolle/DarkRoom}"
: "${GITEA_TOKEN:?set GITEA_TOKEN to a token that can write releases}"
if [[ $# -lt 2 ]]; then
echo "usage: tools/publish-release.sh <tag> <file>..." >&2
exit 2
fi
TAG="$1"
shift
API="${GITEA_URL}/api/v1/repos/${GITEA_REPO}"
AUTH=(-H "Authorization: token ${GITEA_TOKEN}")
for f in "$@"; do
[[ -f "${f}" ]] || { echo "error: ${f} does not exist" >&2; exit 1; }
done
# The checksums, beside the files they describe, named as they will be
# downloaded — `sha256sum -c SHA256SUMS` in a download folder has to work.
WORK="$(mktemp -d)"
trap 'rm -rf "${WORK}"' EXIT
(
for f in "$@"; do
printf '%s %s\n' "$(sha256sum "${f}" | cut -d' ' -f1)" "$(basename "${f}")"
done
) > "${WORK}/SHA256SUMS"
cat "${WORK}/SHA256SUMS"
# The notes, from the server's copy of the tag. A CI checkout of a tag can
# hold it as a lightweight ref with no message, so the local repository is
# not asked.
curl -fsS "${AUTH[@]}" "${API}/tags/${TAG}" > "${WORK}/tag.json" \
|| { echo "error: no tag ${TAG} on ${GITEA_REPO}" >&2; exit 1; }
RELEASE_ID="$(curl -sS "${AUTH[@]}" "${API}/releases/tags/${TAG}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("id") or "")' 2>/dev/null || true)"
if [[ -n "${RELEASE_ID}" ]]; then
echo "==> release ${TAG} exists (id ${RELEASE_ID}); attaching what is missing"
else
python3 - "${TAG}" "${WORK}/tag.json" > "${WORK}/release.json" <<'PY'
import json, sys
tag, path = sys.argv[1], sys.argv[2]
message = (json.load(open(path)).get("message") or "").strip()
if not message:
sys.exit(f"error: {tag} has no message; releases are cut from annotated tags")
title, _, body = message.partition("\n")
print(json.dumps({"tag_name": tag, "name": title.strip(), "body": body.strip(),
"draft": False, "prerelease": False}))
PY
RELEASE_ID="$(curl -fsS "${AUTH[@]}" -H 'Content-Type: application/json' \
--data @"${WORK}/release.json" "${API}/releases" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
echo "==> created release ${TAG} (id ${RELEASE_ID})"
fi
curl -fsS "${AUTH[@]}" "${API}/releases/${RELEASE_ID}/assets" > "${WORK}/assets.json"
for f in "$@" "${WORK}/SHA256SUMS"; do
name="$(basename "${f}")"
size="$(stat -c%s "${f}")"
have="$(python3 -c 'import json,sys
for a in json.load(open(sys.argv[1])):
if a["name"] == sys.argv[2]: print(a["size"])' "${WORK}/assets.json" "${name}")"
if [[ "${have}" == "${size}" ]]; then
echo " ${name}: already attached"
continue
fi
if [[ -n "${have}" ]]; then
echo "error: ${name} is attached at ${have} bytes, not ${size}; remove it by hand" >&2
exit 1
fi
echo " ${name}: uploading ${size} bytes"
curl -fsS "${AUTH[@]}" -F "attachment=@${f}" \
"${API}/releases/${RELEASE_ID}/assets?name=${name}" > /dev/null
done
echo "==> ${GITEA_URL}/${GITEA_REPO}/releases/tag/${TAG}"
+49 -7
View File
@@ -67,17 +67,38 @@ pub const GESTURES: &[Gesture] = &[
Gesture {
title: "Copy the settings from this photograph",
section: "Develop",
touch: "Press Copy in the Settings panel",
pointer: "Press Copy in the Settings panel",
touch: "Press Copy in the top bar",
pointer: "Press Copy in the top bar",
keys: "Ctrl+C",
},
Gesture {
title: "Paste the settings onto this photograph",
section: "Develop",
touch: "Press Paste in the Settings panel",
pointer: "Press Paste in the Settings panel",
touch: "Press Paste in the top bar",
pointer: "Press Paste in the top bar",
keys: "Ctrl+V",
},
Gesture {
title: "Choose which kinds of edit a copy carries",
section: "Develop",
touch: "Open Presets and toggle the kinds",
pointer: "Open Presets and toggle the kinds",
keys: "Ctrl+Shift+C, which offers Copy beside them",
},
Gesture {
title: "Export this photograph as the last one was",
section: "Develop",
touch: "Press Export in the top bar",
pointer: "Press Export in the top bar",
keys: "Ctrl+Shift+E",
},
Gesture {
title: "Choose how to export, then export",
section: "Develop",
touch: "Open Settings, then Export defaults",
pointer: "Open Settings, then Export defaults",
keys: "Ctrl+E",
},
Gesture {
title: "Change which group of adjustments is on screen",
section: "Develop",
@@ -97,7 +118,7 @@ pub const GESTURES: &[Gesture] = &[
section: "Develop",
touch: "Tap a frame in the roll along the foot of the canvas",
pointer: "Click a frame in the roll",
keys: "Right arrow or space for the next, left arrow for the one before",
keys: "Right arrow, D or space for the next; left arrow or A for the one before",
},
Gesture {
title: "See the photograph before you edited it",
@@ -253,6 +274,27 @@ pub const GESTURES: &[Gesture] = &[
pointer: "Open the People chip on the filter bar, click each name, then switch the chip beside them to \"all of them\"",
keys: "",
},
Gesture {
title: "Export the selection as the last export was",
section: "Library grid",
touch: "Select them, then Export in the selection bar",
pointer: "Select them, then Export in the selection bar",
keys: "Ctrl+Shift+E, or Ctrl+E to see the export settings first",
},
Gesture {
title: "Paste copied settings onto the selection",
section: "Library grid",
touch: "Select them, then \"Paste to N\" in the selection bar",
pointer: "Select them, then \"Paste to N\"",
keys: "Ctrl+V",
},
Gesture {
title: "Show only photographs with some number of stars",
section: "Library grid",
touch: "Tap a star chip in the filter bar",
pointer: "Click a star chip in the filter bar",
keys: "Hold F and tap a digit for exactly that many stars, or two digits for everything between them; tap F alone to show every rating again",
},
Gesture {
title: "Resize the thumbnails",
section: "Library grid",
@@ -279,7 +321,7 @@ pub const GESTURES: &[Gesture] = &[
section: "Library grid",
touch: "Tap a star on the cell",
pointer: "Hover the cell, then click a star",
keys: "0 to 5 on the selection",
keys: "0 to 5 with the pointer over it, or on the selection",
},
Gesture {
title: "Choose the frame a folded burst shows",
@@ -300,7 +342,7 @@ pub const GESTURES: &[Gesture] = &[
section: "Library grid",
touch: "While selecting, press \"Select all\"",
pointer: "While selecting, press \"Select all\"",
keys: "",
keys: "Ctrl+A",
},
Gesture {
title: "Take photographs out of a collection",
+34 -30
View File
@@ -1958,42 +1958,46 @@ fn wire_settings_screen(
{
let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start);
window.on_settings_browse_open_picker(move || {
let Some(w) = weak.upgrade() else { return };
// Opens on the library root rather than on whatever the
// destination field happens to contain: a half-typed path
// would list nothing and look like a broken picker.
ctl.browser.replace(Some(launch::FolderBrowser {
path: String::new(),
entries: Vec::new(),
loading: true,
}));
start(&ctl, &weak, &library, String::new());
settings_ui::render(&w, &ctl);
});
window
.global::<ExportOptions>()
.on_browse_open_picker(move || {
let Some(w) = weak.upgrade() else { return };
// Opens on the library root rather than on whatever the
// destination field happens to contain: a half-typed path
// would list nothing and look like a broken picker.
ctl.browser.replace(Some(launch::FolderBrowser {
path: String::new(),
entries: Vec::new(),
loading: true,
}));
start(&ctl, &weak, &library, String::new());
settings_ui::render(&w, &ctl);
});
}
{
let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start);
window.on_settings_browse_into(move |name| {
let Some(w) = weak.upgrade() else { return };
let path = {
let mut browser = ctl.browser.borrow_mut();
let Some(b) = browser.as_mut() else { return };
let path = b.child_path(&name);
b.path = path.clone();
b.entries.clear();
b.loading = true;
path
};
start(&ctl, &weak, &library, path);
settings_ui::render(&w, &ctl);
});
window
.global::<ExportOptions>()
.on_browse_into(move |name| {
let Some(w) = weak.upgrade() else { return };
let path = {
let mut browser = ctl.browser.borrow_mut();
let Some(b) = browser.as_mut() else { return };
let path = b.child_path(&name);
b.path = path.clone();
b.entries.clear();
b.loading = true;
path
};
start(&ctl, &weak, &library, path);
settings_ui::render(&w, &ctl);
});
}
{
let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start);
window.on_settings_browse_up(move || {
window.global::<ExportOptions>().on_browse_up(move || {
let Some(w) = weak.upgrade() else { return };
let path = {
let mut browser = ctl.browser.borrow_mut();
@@ -2011,7 +2015,7 @@ fn wire_settings_screen(
{
let (weak, ctl) = (weak.clone(), ctl.clone());
window.on_settings_browse_confirm(move || {
window.global::<ExportOptions>().on_browse_confirm(move || {
let Some(w) = weak.upgrade() else { return };
// The folder being *shown* is the one chosen, matching the
// library picker — so "use this one" means the same thing in
@@ -2029,7 +2033,7 @@ fn wire_settings_screen(
{
let (weak, ctl) = (weak.clone(), ctl.clone());
window.on_settings_browse_cancel(move || {
window.global::<ExportOptions>().on_browse_cancel(move || {
let Some(w) = weak.upgrade() else { return };
ctl.browser.replace(None);
settings_ui::render(&w, &ctl);
+43 -6
View File
@@ -76,6 +76,11 @@ pub(super) const TRASH_ORDER: &str = "ORDER BY i.trashed_at DESC, i.source_ref A
pub struct RatingFilter {
/// Minimum stars. 0 means no star constraint.
pub min_rating: u8,
/// TRACES: FR-UI-5
/// Maximum stars, for a range — `None` for no ceiling. Set by holding F
/// and tapping two digits in the grid; the filter bar's chips set only
/// the floor and clear this.
pub max_rating: Option<u8>,
/// Only images nothing has judged yet — neither starred nor flagged.
/// This is what lets a culling session resume where it stopped.
pub unjudged: bool,
@@ -175,6 +180,7 @@ impl RatingFilter {
/// Whether this narrows anything, so the caller can skip the join.
pub fn is_unfiltered(&self) -> bool {
self.min_rating == 0
&& self.max_rating.is_none()
&& !self.unjudged
&& self.flag.is_none()
&& !self.local_only
@@ -216,13 +222,15 @@ impl RatingFilter {
pub(super) fn sql(&self) -> String {
let mut terms = Vec::new();
if self.min_rating > 0 {
terms.push(format!(
"coalesce((SELECT dv.rating FROM versions dv
// One subquery for both ends of a range, not one per end: it is a
// correlated read per image either way, and two would be two.
let stars = "coalesce((SELECT dv.rating FROM versions dv
WHERE dv.image_id = i.id AND dv.is_default = 1
LIMIT 1), 0) >= {}",
self.min_rating
));
LIMIT 1), 0)";
match (self.min_rating, self.max_rating) {
(0, None) => {}
(min, None) => terms.push(format!("{stars} >= {min}")),
(min, Some(max)) => terms.push(format!("{stars} BETWEEN {min} AND {max}")),
}
// Integers this code owns, formatted straight in like the rating terms
@@ -453,6 +461,35 @@ mod tests {
assert_eq!(total_images_filtered(&catalog, &filter).unwrap(), 2);
}
// --- star ranges (FR-UI-5) -------------------------------------------
#[test]
fn a_star_range_keeps_both_ends_and_drops_the_rest() {
// Hold F, tap 2 then 3: the twos and threes, not the fours above them
// and not the unrated below.
let catalog = with_images(6);
let ids = image_ids(&catalog);
for (id, stars) in ids.iter().zip([1u8, 2, 3, 4, 5]) {
dr_catalog::rating::set_rating(catalog.connection(), *id, stars).unwrap();
}
let range = RatingFilter {
min_rating: 2,
max_rating: Some(3),
..Default::default()
};
assert_eq!(read_cells_all(&catalog, &range, 0, 120).unwrap().len(), 2);
assert_eq!(total_images_filtered(&catalog, &range).unwrap(), 2);
// A ceiling with no floor reaches down to the unrated.
let no_stars = RatingFilter {
max_rating: Some(0),
..Default::default()
};
assert!(!no_stars.is_unfiltered());
assert_eq!(total_images_filtered(&catalog, &no_stars).unwrap(), 1);
}
#[test]
fn an_empty_cache_is_not_an_empty_library() {
// The unfiltered grid must not depend on the cache table having rows —
+14 -8
View File
@@ -592,6 +592,10 @@ static IN_FLIGHT: std::sync::LazyLock<InFlight> = std::sync::LazyLock::new(InFli
pub(super) struct InFlight {
busy: std::sync::Mutex<std::collections::HashSet<String>>,
freed: std::sync::Condvar,
/// Threads parked in [`InFlight::claim`], counted under the lock so a
/// test can release the holder only once a waiter is really waiting.
#[cfg(test)]
waiting: std::sync::atomic::AtomicUsize,
}
impl InFlight {
@@ -609,6 +613,9 @@ impl InFlight {
path: path.to_string(),
});
}
#[cfg(test)]
self.waiting
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
while busy.contains(path) {
busy = self.freed.wait(busy).unwrap_or_else(|e| e.into_inner());
}
@@ -901,17 +908,16 @@ mod tests {
let first = registry.claim("shoot/one.CR2");
assert!(first.is_some(), "an unclaimed path is claimed outright");
let (tx, rx) = std::sync::mpsc::channel();
let waiter = {
let registry = registry.clone();
std::thread::spawn(move || {
tx.send(()).unwrap();
registry.claim("shoot/one.CR2").is_some()
})
std::thread::spawn(move || registry.claim("shoot/one.CR2").is_some())
};
rx.recv().unwrap();
// The waiter is blocked on the first claim. Not provable without a
// sleep, but a release that reaches it proves the wait ended there.
// Wait until the second claim is parked on the condvar. The count is
// bumped under the lock just before the wait, and the release below
// needs that lock, so it cannot reach the waiter any earlier.
while registry.waiting.load(std::sync::atomic::Ordering::SeqCst) == 0 {
std::thread::yield_now();
}
assert!(
!waiter.is_finished(),
"the second claim must not return while the first is held"
+38
View File
@@ -36,6 +36,10 @@ pub(super) fn wire_filter_ratings_and_people(window: &AppWindow, ctl: &Rc<Librar
.on_library_filter_min_rating_changed(move |n| {
let Some(w) = weak.upgrade() else { return };
ctl.filter.borrow_mut().min_rating = n.clamp(0, 5) as u8;
// The chips say "n or more", so a chip replaces a range
// rather than narrowing it from below.
ctl.filter.borrow_mut().max_rating = None;
w.global::<Library>().set_library_filter_max_rating(5);
// Stars and "unrated" are contradictory terms — asking for four
// stars *and* nothing judged matches nothing at all, which reads
// as a broken filter rather than an impossible question.
@@ -50,6 +54,38 @@ pub(super) fn wire_filter_ratings_and_people(window: &AppWindow, ctl: &Rc<Librar
});
}
// TRACES: FR-UI-5
// A star range from the keyboard: hold F and tap one digit for exactly
// that many stars, two for everything between them. Five as the ceiling
// is no ceiling, so "F, 2, 5" and the "2+" chip are the same filter and
// the bar shows them the same way.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Library>()
.on_library_filter_rating_range(move |low, high| {
let Some(w) = weak.upgrade() else { return };
let (low, high) = (low.clamp(0, 5), high.clamp(0, 5));
let (low, high) = (low.min(high), low.max(high));
{
let mut f = ctl.filter.borrow_mut();
f.min_rating = low as u8;
f.max_rating = (high < 5).then_some(high as u8);
// As for the chips: stars and "nothing judged" cannot
// both hold once a star is asked for.
if low > 0 {
f.unjudged = false;
}
}
let lib = w.global::<Library>();
lib.set_library_filter_min_rating(low);
lib.set_library_filter_max_rating(high);
lib.set_library_filter_unjudged(ctl.filter.borrow().unjudged);
refilter(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
@@ -63,12 +99,14 @@ pub(super) fn wire_filter_ratings_and_people(window: &AppWindow, ctl: &Rc<Librar
// See above: the two cannot both hold.
if on {
f.min_rating = 0;
f.max_rating = None;
f.flag = None;
}
}
w.global::<Library>().set_library_filter_unjudged(on);
if on {
w.global::<Library>().set_library_filter_min_rating(0);
w.global::<Library>().set_library_filter_max_rating(5);
w.global::<Library>().set_library_filter_flag(0);
}
refilter(&w, &ctl);
+53 -3
View File
@@ -931,16 +931,23 @@ pub(super) fn wire_ratings_and_flags(
}
// A rating or flag key. Applies to the whole selection, which is what
// makes judging a run of frames one keystroke rather than forty.
// makes judging a run of frames one keystroke rather than forty — unless
// the pointer is over a photograph outside it, which then takes the key
// alone (darktable's rule; see `judged_images`).
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll_for_keys = coll_ctl.clone();
window
.global::<Library>()
.on_library_judged(move |rating, flag| {
.on_library_judged(move |rating, flag, hovered| {
let Some(w) = weak.upgrade() else { return };
let chosen = coll_for_keys.selected();
let offset = w.global::<Library>().get_library_offset().max(0) as usize;
let under_pointer = usize::try_from(hovered)
.ok()
.and_then(|ordinal| ordinal.checked_sub(offset))
.and_then(|row| ctl.visible_ids().get(row).copied());
let chosen = judged_images(coll_for_keys.selected(), under_pointer);
// Exactly one axis is meant per keystroke; the other arrives as
// -1 so a star press cannot disturb a flag or the reverse.
@@ -953,6 +960,24 @@ pub(super) fn wire_ratings_and_flags(
}
}
/// TRACES: FR-UI-5
/// What a judgement key applies to: the photograph under the pointer when it
/// is not part of the selection, and the selection otherwise.
///
/// Hovering one of the selected frames rates all of them, so the pointer
/// resting on the grid never shrinks a forty-frame judgement to one. With
/// the pointer off the grid, or over a cell the grid has since scrolled away
/// from, it is the selection as it always was.
fn judged_images(
selection: Vec<dr_types::ImageId>,
under_pointer: Option<dr_types::ImageId>,
) -> Vec<dr_types::ImageId> {
match under_pointer {
Some(id) if !selection.contains(&id) => vec![id],
_ => selection,
}
}
/// --- keywords (FR-CAT-5, FR-CAT-6) ------------------------------------
///
/// Three callbacks and no state of their own: the sheet's open/shut is local
@@ -1003,6 +1028,31 @@ pub(super) fn wire_keywords(
mod tests {
use super::*;
// --- what a judgement key reaches (FR-UI-5) ----------------------------
#[test]
fn a_hovered_photograph_outside_the_selection_is_judged_alone() {
let (a, b, c) = (
dr_types::ImageId(1),
dr_types::ImageId(2),
dr_types::ImageId(3),
);
assert_eq!(judged_images(vec![a, b], Some(c)), vec![c]);
}
#[test]
fn hovering_a_selected_photograph_judges_the_whole_selection() {
let (a, b) = (dr_types::ImageId(1), dr_types::ImageId(2));
assert_eq!(judged_images(vec![a, b], Some(b)), vec![a, b]);
}
#[test]
fn with_nothing_hovered_the_selection_is_judged() {
let (a, b) = (dr_types::ImageId(1), dr_types::ImageId(2));
assert_eq!(judged_images(vec![a, b], None), vec![a, b]);
assert_eq!(judged_images(vec![], None), vec![]);
}
// --- what the status line says about a keyword (FR-CAT-5) -------------
//
// Split out from the callback for the same reason `decide_drop` is: the
+3
View File
@@ -903,6 +903,9 @@ fn push_filter(window: &AppWindow, ctl: &Rc<LibraryController>) {
window
.global::<Library>()
.set_library_filter_min_rating(filter.min_rating as i32);
window
.global::<Library>()
.set_library_filter_max_rating(filter.max_rating.map_or(5, i32::from));
window
.global::<Library>()
.set_library_filter_unjudged(filter.unjudged);
+13
View File
@@ -24,6 +24,7 @@ impl From<&RatingFilter> for StoredFilter {
fn from(f: &RatingFilter) -> Self {
Self {
min_rating: f.min_rating,
max_rating: f.max_rating,
unjudged: f.unjudged,
flag: f.flag,
local_only: f.local_only,
@@ -40,6 +41,7 @@ impl From<&StoredFilter> for RatingFilter {
fn from(s: &StoredFilter) -> Self {
Self {
min_rating: s.min_rating,
max_rating: s.max_rating.map(|m| m.min(5)),
unjudged: s.unjudged,
flag: s.flag,
local_only: s.local_only,
@@ -178,6 +180,7 @@ mod tests {
captured_at: Some(1_554_300_000),
filter: StoredFilter {
min_rating: 3,
max_rating: None,
unjudged: false,
flag: Some(dr_types::FlagState::Pick),
local_only: true,
@@ -300,6 +303,7 @@ mod tests {
// restored place shows a different set than the one that was left.
let original = RatingFilter {
min_rating: 4,
max_rating: Some(4),
unjudged: true,
flag: Some(dr_types::FlagState::Reject),
local_only: true,
@@ -318,6 +322,15 @@ mod tests {
assert!(back.is_unfiltered());
}
#[test]
fn a_record_from_before_star_ranges_has_no_ceiling() {
// TRACES: FR-UI-5
// Another device on an older build writes records without the field.
let old: StoredFilter = serde_json::from_str(r#"{"min_rating":2}"#).unwrap();
let back: RatingFilter = (&old).into();
assert_eq!((back.min_rating, back.max_rating), (2, None));
}
#[test]
fn a_scope_travels_as_a_uuid() {
// The guard against someone reaching for `collections.id`, which the
+200 -106
View File
@@ -32,7 +32,7 @@ use dr_types::{
use slint::ComponentHandle;
use crate::settings_store::SettingsStore;
use crate::{Adjustments, AppWindow, Page};
use crate::{Adjustments, AppWindow, ExportOptions, Page};
use dr_sync::Connection;
/// Shared settings state for the running window.
@@ -245,38 +245,56 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
// Choice rows are sent as labels plus the selected index rather than as a
// model of structs: the page draws a row of chips from them and nothing
// else, so a label and an index is the whole of what it needs.
window.set_settings_format_labels(labels(ExportFormat::ALL.iter().map(|f| f.label())));
window.set_settings_format_selected(index_of(&ExportFormat::ALL, &s.export.format));
window
.global::<ExportOptions>()
.set_format_labels(labels(ExportFormat::ALL.iter().map(|f| f.label())));
window
.global::<ExportOptions>()
.set_format_selected(index_of(&ExportFormat::ALL, &s.export.format));
window.set_settings_quality(s.export.quality as i32);
window
.global::<ExportOptions>()
.set_quality(s.export.quality as i32);
// Disabled rather than hidden for a lossless format: a control that
// vanishes when PNG is picked reads as a bug, where a greyed one explains
// itself.
window.set_settings_quality_enabled(s.export.format.is_lossy());
window
.global::<ExportOptions>()
.set_quality_enabled(s.export.format.is_lossy());
window.set_settings_colour_labels(labels(ColourSpace::ALL.iter().map(|c| c.label())));
window.set_settings_colour_selected(index_of(&ColourSpace::ALL, &s.export.colour_space));
window
.global::<ExportOptions>()
.set_colour_labels(labels(ColourSpace::ALL.iter().map(|c| c.label())));
window
.global::<ExportOptions>()
.set_colour_selected(index_of(&ColourSpace::ALL, &s.export.colour_space));
window.set_settings_sizing_labels(labels(SizingMode::CHOICES.iter().map(|m| m.label())));
window
.global::<ExportOptions>()
.set_sizing_labels(labels(SizingMode::CHOICES.iter().map(|m| m.label())));
// Compared by variant, not by equality: `LongEdge(900)` after the user
// typed their own number is still the "Long edge" choice, and equality
// against `CHOICES` would light nothing.
window.set_settings_sizing_selected(
window.global::<ExportOptions>().set_sizing_selected(
SizingMode::CHOICES
.iter()
.position(|m| m.same_mode(s.export.sizing))
.unwrap_or(0) as i32,
);
window.set_settings_sizing_value(s.export.sizing.value().unwrap_or(0) as i32);
window
.global::<ExportOptions>()
.set_sizing_value(s.export.sizing.value().unwrap_or(0) as i32);
// `Original` carries no number, so the field beside the chips has nothing
// to edit and is hidden rather than shown holding a meaningless zero.
window.set_settings_sizing_has_value(s.export.sizing.value().is_some());
window
.global::<ExportOptions>()
.set_sizing_has_value(s.export.sizing.value().is_some());
// TRACES: FR-EXP-3
// The unit carries the axis for the box modes, so that two numeric fields
// one above the other are not two anonymous numbers. It reads "3840 px
// wide" and "2160 px high", which is the sentence the user is trying to
// write anyway.
window.set_settings_sizing_unit(
window.global::<ExportOptions>().set_sizing_unit(
match s.export.sizing {
SizingMode::Percentage(_) => "%",
SizingMode::FitBox(..) | SizingMode::FillBox(..) => "px wide",
@@ -289,7 +307,7 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
// partner and both stop being "the size value" — one is the width and one
// is the height, and a page that called either of them "Size value" would
// leave the user guessing which axis they were editing.
window.set_settings_sizing_value_label(
window.global::<ExportOptions>().set_sizing_value_label(
if s.export.sizing.height().is_some() {
"Width"
} else {
@@ -297,15 +315,21 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
}
.into(),
);
window.set_settings_sizing_height(s.export.sizing.height().unwrap_or(0) as i32);
window.set_settings_sizing_has_height(s.export.sizing.height().is_some());
window
.global::<ExportOptions>()
.set_sizing_height(s.export.sizing.height().unwrap_or(0) as i32);
window
.global::<ExportOptions>()
.set_sizing_has_height(s.export.sizing.height().is_some());
// TRACES: FR-EXP-3
// The panels, offered as buttons. Lit only on an exact match, and -1 —
// no chip lit — where the user has typed their own numbers: a preset that
// stayed selected while the fields said something else would be the page
// claiming the export is 4K when it is not.
window.set_settings_screen_labels(labels(ScreenSize::ALL.iter().map(|p| p.label)));
window.set_settings_screen_selected(
window
.global::<ExportOptions>()
.set_screen_labels(labels(ScreenSize::ALL.iter().map(|p| p.label)));
window.global::<ExportOptions>().set_screen_selected(
ScreenSize::ALL
.iter()
.position(|p| {
@@ -314,42 +338,68 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
})
.map_or(-1, |i| i as i32),
);
window.set_settings_allow_upscaling(s.export.allow_upscaling);
window
.global::<ExportOptions>()
.set_allow_upscaling(s.export.allow_upscaling);
window.set_settings_sharpening_labels(labels(OutputSharpening::ALL.iter().map(|x| x.label())));
window.set_settings_sharpening_selected(index_of(&OutputSharpening::ALL, &s.export.sharpening));
window
.global::<ExportOptions>()
.set_sharpening_labels(labels(OutputSharpening::ALL.iter().map(|x| x.label())));
window
.global::<ExportOptions>()
.set_sharpening_selected(index_of(&OutputSharpening::ALL, &s.export.sharpening));
window.set_settings_filename_template(s.export.filename_template.clone().into());
window.set_settings_collision_labels(labels(CollisionPolicy::ALL.iter().map(|c| c.label())));
window.set_settings_collision_selected(index_of(&CollisionPolicy::ALL, &s.export.collision));
window.set_settings_strip_location(s.export.strip_location);
window
.global::<ExportOptions>()
.set_filename_template(s.export.filename_template.clone().into());
window
.global::<ExportOptions>()
.set_collision_labels(labels(CollisionPolicy::ALL.iter().map(|c| c.label())));
window
.global::<ExportOptions>()
.set_collision_selected(index_of(&CollisionPolicy::ALL, &s.export.collision));
window
.global::<ExportOptions>()
.set_strip_location(s.export.strip_location);
// `available()`, not `ALL`: Android cannot write to a device folder, and
// offering a target that fails at the last step is what made export look
// broken there rather than absent.
let targets = ExportTarget::available();
window.set_settings_target_labels(labels(targets.iter().map(|t| t.label())));
window.set_settings_target_selected(index_of(targets, &s.export.target));
window.set_settings_destination(s.export.active_destination().into());
window
.global::<ExportOptions>()
.set_target_labels(labels(targets.iter().map(|t| t.label())));
window
.global::<ExportOptions>()
.set_target_selected(index_of(targets, &s.export.target));
window
.global::<ExportOptions>()
.set_destination(s.export.active_destination().into());
// Derived from the target itself, never from its position in a list whose
// length differs by platform.
window.set_settings_browse_available(s.export.target.is_remote());
window
.global::<ExportOptions>()
.set_browse_available(s.export.target.is_remote());
// The field means different things either side of the choice, and a
// placeholder saying which is cheaper than a paragraph under it.
// The placeholder names what an empty field *means*, which differs by
// target: on a filesystem it is a question, on the server it is the
// library root.
window.set_settings_destination_hint(s.export.destination_label().into());
window
.global::<ExportOptions>()
.set_destination_hint(s.export.destination_label().into());
// --- the remote folder picker --------------------------------------
{
let browser = controller.browser.borrow();
window.set_settings_browse_open(browser.is_some());
window
.global::<ExportOptions>()
.set_browse_open(browser.is_some());
match browser.as_ref() {
Some(b) => {
// The root is shown as a word rather than as an empty string,
// which would read as a control that had lost its value.
window.set_settings_browse_path(
window.global::<ExportOptions>().set_browse_path(
if b.path.is_empty() {
"Library root".to_string()
} else {
@@ -357,20 +407,29 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
}
.into(),
);
window.set_settings_browse_loading(b.loading);
window.set_settings_browse_at_root(b.parent_path().is_none());
window.set_settings_browse_entries(slint::ModelRc::new(slint::VecModel::from(
b.entries
.iter()
.map(|e| slint::SharedString::from(e.as_str()))
.collect::<Vec<_>>(),
)));
window
.global::<ExportOptions>()
.set_browse_loading(b.loading);
window
.global::<ExportOptions>()
.set_browse_at_root(b.parent_path().is_none());
window
.global::<ExportOptions>()
.set_browse_entries(slint::ModelRc::new(slint::VecModel::from(
b.entries
.iter()
.map(|e| slint::SharedString::from(e.as_str()))
.collect::<Vec<_>>(),
)));
}
None => {
window.set_settings_browse_entries(slint::ModelRc::new(slint::VecModel::from(
Vec::<slint::SharedString>::new(),
)));
window.set_settings_browse_loading(false);
window
.global::<ExportOptions>()
.set_browse_entries(slint::ModelRc::new(slint::VecModel::from(Vec::<
slint::SharedString,
>::new(
))));
window.global::<ExportOptions>().set_browse_loading(false);
}
}
}
@@ -440,6 +499,21 @@ fn wire_open_close(
});
}
// TRACES: FR-EXP-7 | FR-UI-5
// The export sheet shows the same values as the settings page, so it is
// read from disk and rendered the same way before it opens — without the
// page, and without `on_open`, which prepares the page's other panels.
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_export_options_open(move || {
let Some(w) = weak.upgrade() else { return };
*ctl.settings.borrow_mut() = ctl.store.load();
render(&w, &ctl);
w.set_export_sheet_open(true);
});
}
{
let weak = window.as_weak();
window.on_settings_close(move || {
@@ -620,7 +694,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
});
}
window.on_settings_format_picked(move |i| {
window.global::<ExportOptions>().on_format_picked(move |i| {
let Some(w) = weak.upgrade() else { return };
if let Some(f) = ExportFormat::ALL.get(i as usize).copied() {
ctl.edit(|s| s.export.format = f);
@@ -632,13 +706,15 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_settings_quality_changed(move |q| {
let Some(w) = weak.upgrade() else { return };
// Cast before clamping: a negative from the control would wrap to a
// large `u8` and land on 100 instead of the floor.
ctl.edit(|s| s.export.quality = q.clamp(1, 100) as u8);
render(&w, &ctl);
});
window
.global::<ExportOptions>()
.on_quality_changed(move |q| {
let Some(w) = weak.upgrade() else { return };
// Cast before clamping: a negative from the control would wrap to a
// large `u8` and land on 100 instead of the floor.
ctl.edit(|s| s.export.quality = q.clamp(1, 100) as u8);
render(&w, &ctl);
});
}
{
@@ -656,7 +732,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_settings_colour_picked(move |i| {
window.global::<ExportOptions>().on_colour_picked(move |i| {
let Some(w) = weak.upgrade() else { return };
if let Some(c) = ColourSpace::ALL.get(i as usize).copied() {
ctl.edit(|s| s.export.colour_space = c);
@@ -668,7 +744,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_settings_sizing_picked(move |i| {
window.global::<ExportOptions>().on_sizing_picked(move |i| {
let Some(w) = weak.upgrade() else { return };
if let Some(mode) = SizingMode::CHOICES.get(i as usize).copied() {
// Keeps the number the user already typed when they move
@@ -700,27 +776,33 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_settings_sizing_value_changed(move |text| {
let Some(w) = weak.upgrade() else { return };
match text.trim().parse::<u32>() {
Ok(v) if v > 0 => ctl.edit(|s| s.export.sizing = s.export.sizing.with_value(v)),
_ => log::debug!("ignoring an unusable export size: {text:?}"),
}
render(&w, &ctl);
});
window
.global::<ExportOptions>()
.on_sizing_value_changed(move |text| {
let Some(w) = weak.upgrade() else { return };
match text.trim().parse::<u32>() {
Ok(v) if v > 0 => ctl.edit(|s| s.export.sizing = s.export.sizing.with_value(v)),
_ => log::debug!("ignoring an unusable export size: {text:?}"),
}
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_settings_sizing_height_changed(move |text| {
let Some(w) = weak.upgrade() else { return };
match text.trim().parse::<u32>() {
Ok(v) if v > 0 => ctl.edit(|s| s.export.sizing = s.export.sizing.with_height(v)),
_ => log::debug!("ignoring an unusable export height: {text:?}"),
}
render(&w, &ctl);
});
window
.global::<ExportOptions>()
.on_sizing_height_changed(move |text| {
let Some(w) = weak.upgrade() else { return };
match text.trim().parse::<u32>() {
Ok(v) if v > 0 => {
ctl.edit(|s| s.export.sizing = s.export.sizing.with_height(v))
}
_ => log::debug!("ignoring an unusable export height: {text:?}"),
}
render(&w, &ctl);
});
}
{
@@ -734,7 +816,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
// photograph for a user who wanted them.
let weak = window.as_weak();
let ctl = controller.clone();
window.on_settings_screen_picked(move |i| {
window.global::<ExportOptions>().on_screen_picked(move |i| {
let Some(w) = weak.upgrade() else { return };
if let Some(p) = ScreenSize::ALL.get(i.max(0) as usize) {
ctl.edit(|s| s.export.sizing = s.export.sizing.with_box(p.width, p.height));
@@ -746,71 +828,83 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_settings_upscaling_toggled(move |on| {
let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.export.allow_upscaling = on);
render(&w, &ctl);
});
window
.global::<ExportOptions>()
.on_upscaling_toggled(move |on| {
let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.export.allow_upscaling = on);
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_settings_sharpening_picked(move |i| {
let Some(w) = weak.upgrade() else { return };
if let Some(x) = OutputSharpening::ALL.get(i as usize).copied() {
ctl.edit(|s| s.export.sharpening = x);
}
render(&w, &ctl);
});
window
.global::<ExportOptions>()
.on_sharpening_picked(move |i| {
let Some(w) = weak.upgrade() else { return };
if let Some(x) = OutputSharpening::ALL.get(i as usize).copied() {
ctl.edit(|s| s.export.sharpening = x);
}
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_settings_template_changed(move |text| {
let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.export.filename_template = text.to_string());
render(&w, &ctl);
});
window
.global::<ExportOptions>()
.on_template_changed(move |text| {
let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.export.filename_template = text.to_string());
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_settings_collision_picked(move |i| {
let Some(w) = weak.upgrade() else { return };
if let Some(c) = CollisionPolicy::ALL.get(i as usize).copied() {
ctl.edit(|s| s.export.collision = c);
}
render(&w, &ctl);
});
window
.global::<ExportOptions>()
.on_collision_picked(move |i| {
let Some(w) = weak.upgrade() else { return };
if let Some(c) = CollisionPolicy::ALL.get(i as usize).copied() {
ctl.edit(|s| s.export.collision = c);
}
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_settings_strip_location_toggled(move |on| {
let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.export.strip_location = on);
render(&w, &ctl);
});
window
.global::<ExportOptions>()
.on_strip_location_toggled(move |on| {
let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.export.strip_location = on);
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_settings_destination_changed(move |text| {
let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.export.set_active_destination(text.to_string()));
render(&w, &ctl);
});
window
.global::<ExportOptions>()
.on_destination_changed(move |text| {
let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.export.set_active_destination(text.to_string()));
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_settings_target_changed(move |i| {
window.global::<ExportOptions>().on_target_picked(move |i| {
let Some(w) = weak.upgrade() else { return };
if let Some(&t) = ExportTarget::available().get(i.max(0) as usize) {
// Nothing is cleared. Each target keeps its own destination
@@ -1021,7 +1115,7 @@ mod tests {
fn switching_between_sized_modes_keeps_the_typed_number() {
let ctl = controller("sizing");
ctl.edit(|s| s.export.sizing = SizingMode::LongEdge(900));
// What `on_settings_sizing_picked` does for a sized target.
// What `ExportOptions::on_sizing_picked` does for a sized target.
ctl.edit(|s| {
s.export.sizing = match s.export.sizing.value() {
Some(v) => SizingMode::ShortEdge(0).with_value(v),
-58
View File
@@ -886,64 +886,6 @@ export global Transfer {
callback open-presets();
}
// TRACES: FR-DEV-6
// Copying this photograph's settings, and pasting settings onto it.
//
// Buttons rather than a keyboard shortcut *alone*, because this has to work on
// a tablet where there is no modifier key to hold and no menu bar to hang the
// action from. The desktop shortcuts exist as well, wired in Rust; they are an
// accelerator for a control that is on screen either way, which is what keeps
// the feature discoverable on both platforms.
//
// The paste button carries what would be pasted rather than the bare word.
// "Paste" alone asks the user to remember what they copied and, crucially,
// whether the crop is coming with it — a question the label answers by
// naming the count the *current* scope would apply.
export component TransferPanel inherits VerticalLayout {
padding: Theme.gap;
spacing: Theme.gap-sm;
HorizontalLayout {
PanelHeading { text: "SETTINGS"; }
Rectangle { horizontal-stretch: 1; }
}
HorizontalLayout {
spacing: Theme.gap-sm;
Button {
text: "Copy";
enabled: Develop.enabled;
horizontal-stretch: 1;
clicked => { Transfer.copy(); }
}
Button {
text: "Paste";
// Enabled on `armed` rather than on the summary being non-empty,
// so pasting a neutral copy — which clears this image — stays
// available. Still needs an image to paste *onto*.
enabled: Develop.enabled && Transfer.armed;
horizontal-stretch: 1;
clicked => { Transfer.paste(); }
}
}
// TRACES: FR-DEV-6
// The saved half. On its own row rather than a third of the one above,
// because copy and paste are a pair — one arms the other — and a button
// that does neither sitting between them would read as part of that pair.
Button {
text: "Presets…";
enabled: Develop.enabled;
clicked => { Transfer.open-presets(); }
}
if Transfer.armed: Caption {
text: Transfer.summary + (Transfer.framing-withheld ? " · crop not included" : "");
}
}
/// TRACES: FR-DEV-3
/// The generated controls: what they show, what they are pointed at, and what
/// pressing one means.
+182 -133
View File
@@ -1,6 +1,6 @@
import { Theme } from "theme.slint";
import { Develop, ViewMode } from "session.slint";
import { AdjustPanel, Adjustments, ComposePanel, Framing, GroupStrip, ParamRow, Transfer, TransferPanel } from "adjust.slint";
import { AdjustPanel, Adjustments, ComposePanel, Framing, GroupStrip, ParamRow, Transfer } from "adjust.slint";
import { CategoryRow, GradientHandle, GradientHandles, HandleRole, MaskPanel, MaskRow, Masking, SubjectRow } from "masks.slint";
import { Repair, SpotHandle, SpotHandles, SpotPanel, SpotRole } from "spots.slint";
import { CropOverlay } from "crop.slint";
@@ -14,7 +14,8 @@ import { CollectionsPanel, CollectionRow, Collections, OfflinePrompt, Collection
MembershipSheet, MembershipRow } from "collections.slint";
import { HistogramPanel, HistogramView, Levels } from "histogram.slint";
import { RecoveryPrompt } from "recovery.slint";
import { PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
import { CopySheet, PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
import { ExportOptions, ExportSheet } from "export.slint";
import { FocusMarks, FocusPanel, Peaking } from "peaking.slint";
import { SettingsPage } from "settings.slint";
import { ImportPage } from "import.slint";
@@ -33,6 +34,7 @@ export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole }
export { Develop, Capture, Levels, Peaking, Adjustments, Framing, Transfer, Masking, Repair, Steps }
export { Collections }
export { Library }
export { ExportOptions }
/// Which top-level screen is on display. Exactly one at a time — replacing
/// the `show-launch` / `show-library` / `show-identity` booleans, with
@@ -607,39 +609,6 @@ export component AppWindow inherits Window {
in property <[string]> settings-group-nav-labels;
in property <int> settings-group-nav-selected: 0;
in property <string> settings-group-nav-auto-says;
in property <[string]> settings-format-labels;
in property <int> settings-format-selected: 0;
in property <int> settings-quality: 90;
in property <bool> settings-quality-enabled: true;
in property <[string]> settings-colour-labels;
in property <int> settings-colour-selected: 0;
in property <[string]> settings-sizing-labels;
in property <int> settings-sizing-selected: 0;
in property <int> settings-sizing-value: 0;
in property <bool> settings-sizing-has-value: false;
in property <string> settings-sizing-unit: "px";
in property <string> settings-sizing-value-label: "Size value";
in property <int> settings-sizing-height: 0;
in property <bool> settings-sizing-has-height: false;
in property <[string]> settings-screen-labels;
in property <int> settings-screen-selected: -1;
in property <bool> settings-allow-upscaling: false;
in property <[string]> settings-sharpening-labels;
in property <int> settings-sharpening-selected: 0;
in property <string> settings-filename-template: "";
in property <[string]> settings-collision-labels;
in property <int> settings-collision-selected: 0;
in property <bool> settings-strip-location: true;
in property <string> settings-destination: "";
in property <string> settings-destination-hint;
in property <bool> settings-browse-open: false;
in property <string> settings-browse-path;
in property <[string]> settings-browse-entries;
in property <bool> settings-browse-loading: false;
in property <bool> settings-browse-at-root: true;
in property <bool> settings-browse-available: false;
in property <[string]> settings-target-labels;
in property <int> settings-target-selected: 0;
in property <string> settings-error: "";
/// TRACES: FR-CULL-8
@@ -649,25 +618,6 @@ export component AppWindow inherits Window {
callback settings-face-detector-picked(int);
callback settings-group-nav-picked(int);
callback settings-format-picked(int);
callback settings-quality-changed(int);
callback settings-colour-picked(int);
callback settings-sizing-picked(int);
callback settings-sizing-value-changed(string);
callback settings-sizing-height-changed(string);
callback settings-screen-picked(int);
callback settings-upscaling-toggled(bool);
callback settings-sharpening-picked(int);
callback settings-template-changed(string);
callback settings-collision-picked(int);
callback settings-strip-location-toggled(bool);
callback settings-destination-changed(string);
callback settings-target-changed(int);
callback settings-browse-open-picker();
callback settings-browse-into(string);
callback settings-browse-up();
callback settings-browse-confirm();
callback settings-browse-cancel();
callback settings-reset();
/// Show the settings page. Reads the file first, so a second instance's
@@ -675,6 +625,37 @@ export component AppWindow inherits Window {
callback settings-open();
callback settings-close();
/// TRACES: FR-EXP-7 | FR-UI-5
/// The export sheet: the export defaults on their own with an Export
/// button, over develop or the grid. Ctrl+E. Opened through Rust, which
/// reads the settings file first for the reason `settings-open` does.
in-out property <bool> export-sheet-open: false;
callback export-options-open();
/// TRACES: FR-DEV-6 | FR-UI-5
/// The copy sheet: which kinds of edit a copy carries, and Copy. Ctrl+Shift+C.
in-out property <bool> copy-sheet-open: false;
/// TRACES: FR-UI-5
/// One photograph along, from the keyboard.
///
/// `next-image` and `prev-image` walk the files given on the command
/// line, and a photograph opened from a library leaves that list empty —
/// so with a library open the arrows, space, A and D did nothing at all,
/// and only a click on the roll moved on. With a library the step is the
/// roll's own: open its neighbouring frame, exactly as clicking it would.
function step-photo(delta: int) {
if (Library.library-total > 0) {
if (Library.library-roll-current + delta >= 0) {
Library.library-roll-pick(Library.library-roll-current + delta);
}
} else if (delta > 0) {
root.next-image();
} else {
root.prev-image();
}
}
// FR-UI-1: layout class follows window width, not device type. A narrow
// desktop window gets the compact layout, exactly as a tablet would.
//
@@ -935,6 +916,16 @@ in property <bool> panel-visible: true;
root.recovery-dismiss();
return accept;
}
// The two shortcut sheets, which are drawn over everything
// but the recovery question.
if (root.export-sheet-open) {
root.export-sheet-open = false;
return accept;
}
if (root.copy-sheet-open) {
root.copy-sheet-open = false;
return accept;
}
// The row menu and the membership sheet are drawn over the
// offline prompt, so Back has to reach them first — and the
// menu's own confirmation is a step Back must be able to take
@@ -1086,61 +1077,9 @@ in property <bool> panel-visible: true;
group-nav-labels: root.settings-group-nav-labels;
group-nav-selected: root.settings-group-nav-selected;
group-nav-auto-says: root.settings-group-nav-auto-says;
format-labels: root.settings-format-labels;
format-selected: root.settings-format-selected;
quality: root.settings-quality;
quality-enabled: root.settings-quality-enabled;
colour-labels: root.settings-colour-labels;
colour-selected: root.settings-colour-selected;
sizing-labels: root.settings-sizing-labels;
sizing-selected: root.settings-sizing-selected;
sizing-value: root.settings-sizing-value;
sizing-has-value: root.settings-sizing-has-value;
sizing-unit: root.settings-sizing-unit;
sizing-value-label: root.settings-sizing-value-label;
sizing-height: root.settings-sizing-height;
sizing-has-height: root.settings-sizing-has-height;
screen-labels: root.settings-screen-labels;
screen-selected: root.settings-screen-selected;
allow-upscaling: root.settings-allow-upscaling;
sharpening-labels: root.settings-sharpening-labels;
sharpening-selected: root.settings-sharpening-selected;
filename-template: root.settings-filename-template;
collision-labels: root.settings-collision-labels;
collision-selected: root.settings-collision-selected;
strip-location: root.settings-strip-location;
destination: root.settings-destination;
destination-hint: root.settings-destination-hint;
browse-open: root.settings-browse-open;
browse-path: root.settings-browse-path;
browse-entries: root.settings-browse-entries;
browse-loading: root.settings-browse-loading;
browse-at-root: root.settings-browse-at-root;
browse-available: root.settings-browse-available;
target-labels: root.settings-target-labels;
target-selected: root.settings-target-selected;
error: root.settings-error;
group-nav-picked(i) => { root.settings-group-nav-picked(i); }
format-picked(i) => { root.settings-format-picked(i); }
quality-changed(q) => { root.settings-quality-changed(q); }
colour-picked(i) => { root.settings-colour-picked(i); }
sizing-picked(i) => { root.settings-sizing-picked(i); }
sizing-value-changed(t) => { root.settings-sizing-value-changed(t); }
sizing-height-changed(t) => { root.settings-sizing-height-changed(t); }
screen-picked(i) => { root.settings-screen-picked(i); }
upscaling-toggled(on) => { root.settings-upscaling-toggled(on); }
sharpening-picked(i) => { root.settings-sharpening-picked(i); }
template-changed(t) => { root.settings-template-changed(t); }
collision-picked(i) => { root.settings-collision-picked(i); }
strip-location-toggled(on) => { root.settings-strip-location-toggled(on); }
destination-changed(t) => { root.settings-destination-changed(t); }
target-picked(i) => { root.settings-target-changed(i); }
browse-open-picker() => { root.settings-browse-open-picker(); }
browse-into(n) => { root.settings-browse-into(n); }
browse-up() => { root.settings-browse-up(); }
browse-confirm() => { root.settings-browse-confirm(); }
browse-cancel() => { root.settings-browse-cancel(); }
activity-rows: root.activity-rows;
activity-running: root.activity-running;
@@ -1446,6 +1385,8 @@ in property <bool> panel-visible: true;
can-import: root.import-supported;
open-import() => { root.import-open(); }
open-settings() => { root.settings-open(); }
open-export-options() => { root.export-options-open(); }
sheet-open: root.export-sheet-open;
open-people() => { root.identity-open(); }
cell-pressed(i, ctrl, shift) => {
@@ -1485,6 +1426,10 @@ in property <bool> panel-visible: true;
people: Library.library-people;
gestures: Library.library-gestures;
filter-min-rating: Library.library-filter-min-rating;
filter-max-rating: Library.library-filter-max-rating;
filter-rating-range(low, high) => {
Library.library-filter-rating-range(low, high);
}
filter-unjudged: Library.library-filter-unjudged;
filter-flag: Library.library-filter-flag;
rating-counts: Library.library-rating-counts;
@@ -1501,7 +1446,7 @@ in property <bool> panel-visible: true;
// for the trash row (see collections.slint), and two sources
// for one fact is how they come to disagree.
viewing-trash: root.collection-selected == -1;
judged(rating, flag) => { Library.library-judged(rating, flag); }
judged(rating, flag, at) => { Library.library-judged(rating, flag, at); }
// `F2` renames whatever the grid is scoped to. Answered from
// the sidebar's selection rather than a second property, for
// the same reason `viewing-trash` above is: one fact, one
@@ -2148,6 +2093,14 @@ in property <bool> panel-visible: true;
key-pressed(event) => {
root.shift-held = event.modifiers.shift;
// A sheet is up over the photograph, and these
// keys would act on what it covers: A stepping to
// the next frame behind the export form. Declined
// rather than swallowed, so Escape still reaches
// the shell and closes the sheet.
if (root.export-sheet-open || root.copy-sheet-open) {
return reject;
}
// TRACES: FR-DEV-7
// Hold backslash to see the unedited original.
// Rust ignores a repeat that says what it already
@@ -2205,10 +2158,10 @@ in property <bool> panel-visible: true;
}
// GESTURE: Copy the settings from this photograph
// where: Develop
// touch: Press Copy in the Settings panel
// pointer: Press Copy in the Settings panel
// touch: Press Copy in the top bar
// pointer: Press Copy in the top bar
// keys: Ctrl+C
// why: The panel is the copy that has to
// why: 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
@@ -2217,8 +2170,8 @@ in property <bool> panel-visible: true;
//
// GESTURE: Paste the settings onto this photograph
// where: Develop
// touch: Press Paste in the Settings panel
// pointer: Press Paste in the Settings panel
// touch: Press Paste in the top bar
// pointer: Press Paste in the top bar
// keys: Ctrl+V
// why: The button names what would be
// pasted — "3 adjustments", and
@@ -2227,12 +2180,30 @@ in property <bool> panel-visible: true;
// paste the same scope.
//
// TRACES: FR-DEV-6
// The desktop accelerators for the two buttons in
// the Settings panel, which until now the panel's
// own comment claimed existed and nothing bound.
// The desktop accelerators for the Copy and Paste
// buttons in the top bar.
// Guarded on `settings-armed` for the reason the
// button is: a paste with nothing copied is not a
// clear, it is a keystroke that should do nothing.
// GESTURE: Choose which kinds of edit a copy carries
// where: Develop
// touch: Open Presets and toggle the kinds
// pointer: Open Presets and toggle the kinds
// keys: Ctrl+Shift+C, which offers Copy
// beside them
// why: 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.
//
// Shift first: the logical key is "C" under shift,
// which the plain copy below would otherwise take.
if (event.modifiers.control && event.modifiers.shift
&& (event.text == "c" || event.text == "C")) {
root.copy-sheet-open = true;
return accept;
}
if (event.modifiers.control
&& (event.text == "c" || event.text == "C")) {
Transfer.copy();
@@ -2243,6 +2214,43 @@ in property <bool> panel-visible: true;
Transfer.paste();
return accept;
}
// GESTURE: Export this photograph as the last one was
// where: Develop
// touch: Press Export in the top bar
// pointer: Press Export in the top bar
// keys: Ctrl+Shift+E
// why: 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.
//
// GESTURE: Choose how to export, then export
// where: Develop
// touch: Open Settings, then Export defaults
// pointer: Open Settings, then Export defaults
// keys: Ctrl+E
// why: 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.
//
// TRACES: FR-UI-5 | FR-EXP-7
// Both cases of the letter, as for Z: holding
// shift turns "e" into "E". Rust turns away a
// second export while one is running, so the
// chord needs no busy guard of its own.
if (event.modifiers.control
&& (event.text == "e" || event.text == "E")) {
if (event.modifiers.shift) {
root.export-image();
} else {
root.export-options-open();
}
return accept;
}
// GESTURE: Change which group of adjustments is on screen
// where: Develop
// touch: Tap a group in the rail down the left
@@ -2335,18 +2343,27 @@ in property <bool> panel-visible: true;
// touch: Tap a frame in the roll along the
// foot of the canvas
// pointer: Click a frame in the roll
// keys: Right arrow or space for the next,
// left arrow for the one before
// keys: Right arrow, D or space for the
// next; left arrow or A for the one
// before
// why: 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.
if (event.text == Key.RightArrow || event.text == " ") {
root.next-image();
// A and D as well as the arrows, so the left hand
// steps along the roll while the right stays on
// the mouse. Unmodified only: Ctrl+D and Ctrl+A
// are not this.
if (event.text == Key.RightArrow || event.text == " "
|| (!event.modifiers.control
&& (event.text == "d" || event.text == "D"))) {
root.step-photo(1);
return accept;
}
if (event.text == Key.LeftArrow) {
root.prev-image();
if (event.text == Key.LeftArrow
|| (!event.modifiers.control
&& (event.text == "a" || event.text == "A"))) {
root.step-photo(-1);
return accept;
}
return reject;
@@ -2741,18 +2758,6 @@ in property <bool> panel-visible: true;
// `dr_pipeline::framing` on the coordinate order).
if !Develop.local-mode && !Develop.repairing: ComposePanel { }
if !Develop.local-mode && !Develop.repairing: Rectangle {
height: 1px;
background: Theme.rule;
}
// Between the framing and the colour work, because it
// acts on both: a paste is about the whole edit, and
// burying it under thirty sliders would put the one
// control that operates on all of them below all of
// them.
if !Develop.local-mode && !Develop.repairing: TransferPanel { }
Rectangle {
height: 1px;
background: Theme.rule;
@@ -2886,6 +2891,50 @@ in property <bool> panel-visible: true;
dismiss => { root.presets-open = false; }
}
// TRACES: FR-EXP-7 | FR-UI-5
// Over the shell for the preset sheet's reasons: both views open it,
// and the develop column is too narrow for the form. What Export does
// depends on which view is underneath — the open photograph, or the
// grid's selection — so the label and the action are chosen here.
if root.export-sheet-open: ExportSheet {
width: 100%;
height: 100%;
error: root.settings-error;
action-label: root.active-view == View.develop
? root.export-label
: (root.export-to-server
? "Export " + Library.library-selected-count + " to the library"
: "Export " + Library.library-selected-count);
can-export: root.active-view == View.develop
? !root.export-busy && root.total > 0
: !Library.library-exporting && Library.library-selected-count > 0;
export => {
root.export-sheet-open = false;
if (root.active-view == View.develop) {
root.export-image();
} else {
Library.library-export-selection();
}
}
dismiss => { root.export-sheet-open = false; }
}
// TRACES: FR-DEV-6 | FR-UI-5
if root.copy-sheet-open: CopySheet {
width: 100%;
height: 100%;
scope-kinds: root.copy-scope-kinds;
scope-empty: root.copy-scope-empty;
summary: Transfer.summary;
can-copy: root.total > 0 && root.load-error == "";
scope-toggled(name) => { root.copy-scope-toggled(name); }
copy => {
root.copy-sheet-open = false;
Transfer.copy();
}
dismiss => { root.copy-sheet-open = false; }
}
OfflinePrompt {
width: 100%;
height: 100%;
+38
View File
@@ -8,6 +8,8 @@
// front of it.
import { Theme } from "theme.slint";
import { Develop } from "session.slint";
import { Transfer } from "adjust.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel } from "widgets.slint";
// Status strip — surfaces the GPU backend and adapter, which matters during
@@ -160,6 +162,42 @@ export component StatusBar inherits Rectangle {
clicked => { root.redo(); }
}
// TRACES: FR-DEV-6
// Copy and paste, in the strip beside undo for the reason undo is
// here: they act on the whole edit, not on any one panel, and the
// column can be put away. They sat in the column under a "SETTINGS"
// heading, which read as application settings and hid them behind a
// scroll and behind the panel toggle.
//
// The paste button names what would be pasted — "3 adjustments", and
// whether the crop is staying behind — because "Paste" alone asks the
// user to remember what they copied.
Button {
text: "Copy";
enabled: Develop.enabled;
y: (parent.height - self.height) / 2;
clicked => { Transfer.copy(); }
}
// Enabled on `armed` rather than on a non-empty summary, so pasting a
// neutral copy — which clears this image — stays available.
Button {
text: !Transfer.armed ? "Paste"
: "Paste " + Transfer.summary
+ (Transfer.framing-withheld ? ", no crop" : "");
enabled: Develop.enabled && Transfer.armed;
y: (parent.height - self.height) / 2;
clicked => { Transfer.paste(); }
}
// The saved half: this edit, kept under a name.
Button {
text: "Presets…";
enabled: Develop.enabled;
y: (parent.height - self.height) / 2;
clicked => { Transfer.open-presets(); }
}
// Show or hide the develop column. On a tablet the panel is 280px of a
// screen that is mostly photograph, and the whole point of opening an
// image is to look at it — so being able to put the instruments away
+480
View File
@@ -0,0 +1,480 @@
// TRACES: FR-EXP-7 | FR-UI-5
// The export defaults, drawn wherever an export is chosen.
//
// Two places show them: the settings page, among everything else, and the
// export sheet Ctrl+E opens, alone with an Export button beneath. They are
// one set of values — every export runs on the defaults, there is no
// per-export copy — so the state is a global Rust writes once, and the two
// panels below read it, rather than forty properties threaded separately
// through each surface and bound to agree.
import { Theme } from "theme.slint";
import { Button, Label, Value, Caption } from "widgets.slint";
import { Segmented, TextRow, Check, SliderRow } from "controls.slint";
export global ExportOptions {
// --- the image ---------------------------------------------------
in property <[string]> format-labels;
in property <int> format-selected: 0;
in property <int> quality: 90;
in property <bool> quality-enabled: true;
in property <[string]> colour-labels;
in property <int> colour-selected: 0;
in property <[string]> sizing-labels;
in property <int> sizing-selected: 0;
in-out property <int> sizing-value: 0;
in property <bool> sizing-has-value: false;
in property <string> sizing-unit: "px";
/// "Size value" for the modes that take one number, "Width" for the box
/// modes, where a second field sits below it.
in property <string> sizing-value-label: "Size value";
in-out property <int> sizing-height: 0;
in property <bool> sizing-has-height: false;
/// The panels offered as buttons, and which one the numbers match — `-1`
/// where they match none, so a preset never stays lit over fields that
/// have since been typed over.
in property <[string]> screen-labels;
in property <int> screen-selected: -1;
in property <bool> allow-upscaling: false;
in property <[string]> sharpening-labels;
in property <int> sharpening-selected: 0;
// --- the file ----------------------------------------------------
in-out property <string> filename-template;
in property <[string]> collision-labels;
in property <int> collision-selected: 0;
in property <bool> strip-location: true;
in-out property <string> destination;
// What the destination field means depends on this, so the placeholder
// comes from Rust alongside it rather than being written twice here.
in property <string> destination-hint;
in property <[string]> target-labels;
in property <int> target-selected: 0;
// --- the remote folder picker ------------------------------------
//
// The same navigation the launch screen uses to choose a library root,
// driven by the same `FolderBrowser` model in Rust. A folder on the
// server is not something anyone can be expected to type from memory.
in property <bool> browse-open: false;
in property <string> browse-path;
in property <[string]> browse-entries;
in property <bool> browse-loading: false;
/// At the library root, so there is nowhere up to go.
in property <bool> browse-at-root: true;
/// Whether the destination is one that can be walked.
///
/// A boolean from Rust rather than a test on `target-selected`. The index
/// was hardcoded to 1, which was Remote's position while both targets were
/// offered — and the moment Android's list narrowed to Remote alone, that
/// index became 0 and the button vanished on the one platform where it is
/// the *only* way to set a destination. An index into a list whose length
/// varies is not a fact about the target.
in property <bool> browse-available: false;
callback format-picked(int);
callback quality-changed(int);
callback colour-picked(int);
callback sizing-picked(int);
callback sizing-value-changed(string);
callback sizing-height-changed(string);
callback screen-picked(int);
callback upscaling-toggled(bool);
callback sharpening-picked(int);
callback template-changed(string);
callback collision-picked(int);
callback strip-location-toggled(bool);
callback destination-changed(string);
callback target-picked(int);
callback browse-open-picker();
callback browse-into(string);
callback browse-up();
callback browse-confirm();
callback browse-cancel();
}
// What the image becomes: format, quality, colour, size, sharpening. The
// body of a panel, not a panel — each surface draws its own frame and
// heading around it.
export component ExportImageOptions inherits VerticalLayout {
spacing: Theme.gap;
Segmented {
label: "Format";
options: ExportOptions.format-labels;
selected: ExportOptions.format-selected;
picked(i) => { ExportOptions.format-picked(i); }
}
// A bounded number, so it gets the control for one.
//
// This was a free-text field: the range lived in the
// hint and was enforced nowhere, and `to-float()`
// answers 0 for anything unparseable — so a typo saved
// a quality of 0 and the page then showed the 0 back as
// though it had been asked for. The track carries the
// range and the box refuses what it cannot read.
SliderRow {
label: "Quality";
// Says why it is greyed rather than leaving the
// user to work out that PNG has no quality.
hint: ExportOptions.quality-enabled ? "1 to 100"
: "the chosen format is lossless";
value: ExportOptions.quality;
// No meaningful neutral: quality has a sensible
// default but not a *zero*, and a default marker
// partway along a track reads as one.
default-value: 1;
minimum: 1;
maximum: 100;
enabled: ExportOptions.quality-enabled;
changed(v) => { ExportOptions.quality-changed(v); }
}
Segmented {
label: "Colour space";
hint: "profile embedded on export";
options: ExportOptions.colour-labels;
selected: ExportOptions.colour-selected;
picked(i) => { ExportOptions.colour-picked(i); }
}
Segmented {
label: "Size";
options: ExportOptions.sizing-labels;
selected: ExportOptions.sizing-selected;
picked(i) => { ExportOptions.sizing-picked(i); }
}
// Only where the chosen mode carries a number:
// "Original" has none, and a field showing 0 beside it
// would invite the reading "zero pixels".
if ExportOptions.sizing-has-value: TextRow {
label: ExportOptions.sizing-value-label;
text: ExportOptions.sizing-value;
unit: ExportOptions.sizing-unit;
field-width: 90px;
accepted(t) => { ExportOptions.sizing-value-changed(t); }
}
// TRACES: FR-EXP-3
// The second axis, for the box modes only.
if ExportOptions.sizing-has-height: TextRow {
label: "Height";
text: ExportOptions.sizing-height;
unit: "px high";
field-width: 90px;
accepted(t) => { ExportOptions.sizing-height-changed(t); }
}
// TRACES: FR-EXP-3
// Panel sizes as buttons, because the numbers are the
// whole difficulty: a television's art mode accepts one
// resolution and rejects everything else, and getting
// it by typing four digits twice is a step at which a
// photographer discovers they were wrong only after the
// upload.
//
// Alongside the fields rather than instead of them —
// the presets are a shortcut to a pair of numbers, not
// a replacement for being able to say any pair.
if ExportOptions.sizing-has-height: Segmented {
label: "Screen";
hint: "fills in both numbers";
options: ExportOptions.screen-labels;
selected: ExportOptions.screen-selected;
picked(i) => { ExportOptions.screen-picked(i); }
}
Check {
label: "Allow upscaling";
hint: "Off, a request larger than the source exports "
+ "at source size rather than failing.";
checked: ExportOptions.allow-upscaling;
toggled(on) => { ExportOptions.upscaling-toggled(on); }
}
Segmented {
label: "Output sharpening";
hint: "scaled by the resize factor";
options: ExportOptions.sharpening-labels;
selected: ExportOptions.sharpening-selected;
picked(i) => { ExportOptions.sharpening-picked(i); }
}
}
// What the file is called, where it lands, and what leaves with it.
export component ExportFileOptions inherits VerticalLayout {
spacing: Theme.gap;
TextRow {
label: "Filename template";
hint: "{name} {seq} {date} {dimensions} {preset}";
text <=> ExportOptions.filename-template;
field-width: 260px;
placeholder: "{name}";
accepted(t) => { ExportOptions.template-changed(t); }
}
Segmented {
label: "If the file exists";
options: ExportOptions.collision-labels;
selected: ExportOptions.collision-selected;
picked(i) => { ExportOptions.collision-picked(i); }
}
// Where the file lands, before what it is called: on
// Android the answer decides whether an export needs
// the Storage Access Framework at all, and on any
// platform a server destination is reached over a
// network that may not be there.
Segmented {
label: "Export to";
options: ExportOptions.target-labels;
selected: ExportOptions.target-selected;
picked(i) => { ExportOptions.target-picked(i); }
}
TextRow {
label: "Destination";
// What an empty field does, not what it was once
// going to do: nothing asks, and an export with
// no folder is refused and says so in the header.
hint: "a folder on this device; exports are refused until one is set";
text <=> ExportOptions.destination;
field-width: 320px;
placeholder: ExportOptions.destination-hint;
accepted(t) => { ExportOptions.destination-changed(t); }
}
// Offered only for a server destination. A folder on
// this device is chosen by the platform's own dialogue
// or typed; a folder on the server can only be found
// by walking it, and expecting anyone to recall the
// exact spelling of a path three levels down is how a
// destination silently becomes a new folder at the
// root.
if ExportOptions.browse-available && !ExportOptions.browse-open: HorizontalLayout {
alignment: start;
Button {
text: "Choose folder…";
clicked => { ExportOptions.browse-open-picker(); }
}
}
if ExportOptions.browse-open: Rectangle {
background: Theme.ground;
border-radius: Theme.radius;
height: picker.preferred-height + 2 * Theme.gap;
picker := VerticalLayout {
x: Theme.gap;
y: Theme.gap;
width: parent.width - 2 * Theme.gap;
spacing: Theme.gap-sm;
HorizontalLayout {
spacing: Theme.gap-sm;
Button {
text: "↑ Up";
// Disabled rather than hidden at the
// root: a control that vanishes moves
// everything beside it, and the row
// would jump as the user navigates.
enabled: !ExportOptions.browse-at-root;
clicked => { ExportOptions.browse-up(); }
}
Value {
text: ExportOptions.browse-path;
overflow: elide;
horizontal-stretch: 1;
vertical-alignment: center;
}
Caption {
text: ExportOptions.browse-loading ? "Listing…" : "";
vertical-alignment: center;
}
}
// A fixed height rather than one that grows
// with the listing: a folder with sixty
// children would otherwise push the rest of
// the settings page off the bottom.
Rectangle {
height: 180px;
background: Theme.surface;
border-radius: Theme.radius;
Flickable {
x: 4px;
y: 4px;
width: parent.width - 8px;
height: parent.height - 8px;
viewport-height: folders.preferred-height;
folders := VerticalLayout {
width: 100%;
spacing: 2px;
alignment: start;
if ExportOptions.browse-entries.length == 0
&& !ExportOptions.browse-loading: Caption {
text: "No folders here. "
+ "Use this one, or go up.";
}
for name in ExportOptions.browse-entries: Rectangle {
height: 32px;
background: touch.has-hover
? Theme.surface-raised
: transparent;
border-radius: Theme.radius;
Label {
x: Theme.gap-sm;
text: name;
vertical-alignment: center;
overflow: elide;
width: parent.width - 2 * Theme.gap-sm;
}
touch := TouchArea {
clicked => { ExportOptions.browse-into(name); }
}
}
}
}
}
HorizontalLayout {
spacing: Theme.gap-sm;
alignment: end;
Button {
text: "Cancel";
clicked => { ExportOptions.browse-cancel(); }
}
// Confirms the folder currently *shown*,
// not one selected in the list — the same
// rule the library picker follows, so
// "use this one" means the same thing in
// both places.
Button {
text: "Use this folder";
active: true;
clicked => { ExportOptions.browse-confirm(); }
}
}
}
}
Check {
label: "Strip location and personal metadata";
hint: "On. An export is usually the copy that leaves "
+ "this machine, and a location embedded in a "
+ "published photograph cannot be recalled.";
checked: ExportOptions.strip-location;
toggled(on) => { ExportOptions.strip-location-toggled(on); }
}
}
// TRACES: FR-EXP-7 | FR-UI-5
// The export defaults on their own, over whatever is open, with the button
// that uses them — what Ctrl+E opens, and the nearest thing this application
// has to another editor's export dialogue.
//
// Every change here is saved as it is made, exactly as on the settings page,
// because they are the same values: closing without exporting keeps them, and
// the next Ctrl+Shift+E runs on what was chosen here.
export component ExportSheet inherits Rectangle {
/// What pressing Export would do — "Export JPEG", "Export 40 to the
/// library" — from whichever view opened the sheet.
in property <string> action-label: "Export";
/// False when there is nothing to export, or an export is already running.
in property <bool> can-export: true;
/// A save that failed, as the settings page reports it.
in property <string> error;
callback export();
callback dismiss();
background: #000000CC;
// Swallows the taps that miss the card, and closes. First, so the card's
// own controls sit above it.
TouchArea {
clicked => { root.dismiss(); }
}
Rectangle {
// The settings column's width, so the panels lay out as they do there.
width: min(680px, parent.width - 2 * Theme.gap-lg);
height: min(body.preferred-height + footer.preferred-height
+ 3 * Theme.gap-lg,
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;
// Stops a press on the card reaching the scrim behind it.
TouchArea { }
VerticalLayout {
padding: Theme.gap-lg;
spacing: Theme.gap-lg;
// The options scroll and the buttons do not: on a short window the
// form is longer than the card, and an Export button scrolled out
// of sight is a sheet with no way to do what it is for.
Flickable {
vertical-stretch: 1;
viewport-height: body.preferred-height;
body := VerticalLayout {
width: 100%;
spacing: Theme.gap;
alignment: start;
Text {
text: "Export";
color: Theme.ink;
font-size: Theme.text-lg;
font-weight: 600;
}
if root.error != "": Caption {
text: root.error;
warn: true;
wrap: word-wrap;
}
ExportImageOptions {}
ExportFileOptions {}
}
}
footer := HorizontalLayout {
spacing: Theme.gap-sm;
alignment: end;
Button {
text: "Close";
clicked => { root.dismiss(); }
}
Button {
text: root.action-label;
primary: true;
enabled: root.can-export;
clicked => { root.export(); }
}
}
}
}
}
+1 -1
View File
@@ -103,7 +103,7 @@ export component GestureSheet inherits Rectangle {
// The book covers every place, and opens on Develop — a
// title naming the grid was wrong about the first thing
// under it.
text: "How to drive DarkRoom";
text: "Controls and shortcuts";
color: Theme.ink;
font-size: Theme.text-lg;
font-weight: 600;
+279 -80
View File
@@ -635,10 +635,17 @@ export global Library {
callback library-burst-representative-chosen(int);
/// Move the grid selection to the trash — the `Delete` key.
callback library-trash-selection();
/// A judgement key was pressed, applying to the whole selection. One of
/// the two arguments is -1, saying which axis was *not* meant.
callback library-judged(int, int);
/// A judgement key was pressed. One of the first two arguments is -1,
/// saying which axis was *not* meant. The third is the ordinal of the
/// cell under the pointer, or -1: Rust applies the key to that photograph
/// alone unless it is part of the selection (darktable's rule).
callback library-judged(int, int, int);
in property <int> library-filter-min-rating: 0;
/// TRACES: FR-UI-5
/// The top of a star range, 5 where there is none, and the F chord that
/// sets both ends at once.
in property <int> library-filter-max-rating: 5;
callback library-filter-rating-range(int, int);
in property <bool> library-filter-unjudged: false;
in property <int> library-filter-flag: 0;
/// Images per star count, index 0 unrated, for the filter chips.
@@ -1326,8 +1333,10 @@ component HeaderActions inherits HorizontalLayout {
// the photographs, and unconditional for the same reason Settings is: a
// reference you can only reach in some states is one you look for in the
// state where you needed it and do not find.
// "Help" rather than "Gestures": most of what it lists is now keys, and
// a reader looking for a shortcut does not think to look under gestures.
Button {
text: "Gestures";
text: "Help";
y: root.centred ? (root.row-height - self.height) / 2 : 0;
clicked => { root.open-gestures(); }
}
@@ -1507,8 +1516,18 @@ export component LibraryGrid inherits Rectangle {
/// Rating and flag travel on one callback because they are one keystroke
/// as far as the user is concerned; Rust decodes which axis was meant.
/// `rating` is -1 where the key was a flag, and `flag` -1 where it was a
/// star, so neither axis is disturbed by a press on the other.
callback judged(int, int);
/// star, so neither axis is disturbed by a press on the other. The third
/// is `hover-ordinal`: which photograph the pointer is over, if any.
callback judged(int, int, int);
/// TRACES: FR-UI-5
/// The library ordinal of the cell under the pointer, -1 when none.
///
/// An ordinal and not a row of the loaded window, because the window moves
/// under a still pointer as the grid scrolls; and cleared when it scrolls,
/// so a key pressed after a wheel turn falls back to the selection rather
/// than judging whatever used to be under the pointer.
property <int> hover-ordinal: -1;
/// `F2` — rename the collection the grid is scoped to. The key lives with
/// the grid because that is what holds focus in library mode, but the
/// rename itself happens in the sidebar.
@@ -1743,6 +1762,10 @@ export component LibraryGrid inherits Rectangle {
// thumbnail, which is the cost FR-NC-3 exists to avoid.
/// Minimum stars to show. 0 shows everything.
in property <int> filter-min-rating: 0;
/// TRACES: FR-UI-5
/// The top of a star range; 5 is none.
in property <int> filter-max-rating: 5;
callback filter-rating-range(int, int);
/// Show only images nothing has judged yet — FR-CULL-4's "filter to
/// unjudged", which is what lets a culling session resume.
in property <bool> filter-unjudged: false;
@@ -1863,6 +1886,11 @@ export component LibraryGrid inherits Rectangle {
in property <bool> export-to-server: false;
callback export-selection();
callback cancel-export();
/// TRACES: FR-EXP-7 | FR-UI-5
/// The export sheet, which the window draws: Ctrl+E.
callback open-export-options();
/// A sheet is drawn over the grid, so the keys here stand down.
in property <bool> sheet-open: false;
// TRACES: FR-MRG-1
// Merge the selection into a panorama. Two frames at least; the page
// that opens says what it found and asks before anything is written.
@@ -2452,11 +2480,11 @@ export component LibraryGrid inherits Rectangle {
// the two read as the same scale.
FilterChip {
label: "All";
active: root.filter-min-rating == 0 && !root.filter-unjudged
&& root.filter-flag == 0;
active: root.filter-min-rating == 0 && root.filter-max-rating >= 5
&& !root.filter-unjudged && root.filter-flag == 0;
y: (parent.height - self.height) / 2;
clicked => {
root.filter-min-rating-changed(0);
root.filter-rating-range(0, 5);
root.filter-unjudged-toggled(false);
root.filter-flag-changed(0);
}
@@ -2478,16 +2506,34 @@ export component LibraryGrid inherits Rectangle {
icon: "star";
label: n + "+";
count: root.rating-counts.length > n ? root.rating-counts[n] : -1;
active: root.filter-min-rating == n;
// Under a capped range (the F chord), every chip inside
// it is lit, so "2 and 3 only" reads as two chips rather
// than as the "2+" it is not.
active: root.filter-max-rating < 5
? (n >= root.filter-min-rating && n <= root.filter-max-rating)
: root.filter-min-rating == n;
y: (parent.height - self.height) / 2;
// Pressing the active one clears it, so the filter is its
// own undo and "All" is not the only way back.
clicked => {
root.filter-min-rating-changed(
root.filter-min-rating == n ? 0 : n);
root.filter-min-rating == n && root.filter-max-rating >= 5
? 0 : n);
}
}
// TRACES: FR-UI-5
// A ceiling is the one star filter the chips cannot state, so
// it is stated in words while it holds.
if root.filter-max-rating < 5: Caption {
text: root.filter-max-rating == 0 && root.filter-min-rating == 0
? "no stars only"
: (root.filter-min-rating == root.filter-max-rating
? root.filter-min-rating + "★ only"
: root.filter-min-rating + "–" + root.filter-max-rating + "★ only");
vertical-alignment: center;
}
Rectangle { width: Theme.gap; }
FilterChip {
@@ -2884,10 +2930,33 @@ export component LibraryGrid inherits Rectangle {
// the Flickable competes with it for the arrow keys. This holds
// focus and forwards nothing else.
//
// Applies to the **selection**, not to a cell under the pointer —
// that is what makes rating forty frames a single keystroke, and it
// matches what the header's count says is selected.
// Applies to the cell under the pointer when that cell is outside
// the selection, and to the **selection** otherwise — darktable's
// rule. Hovering one of forty selected frames still rates all
// forty in one keystroke; hovering a frame nobody selected rates
// that frame without disturbing the selection. Rust decides, from
// `hover-ordinal`.
judge-keys := FocusScope {
// TRACES: FR-UI-5
// The F chord: held, and the digit that arrived first while
// it was, or -1. See `key-released` and the digits below.
property <bool> f-held: false;
property <int> f-first: -1;
property <bool> f-used: false;
key-released(event) => {
// Tapping F on its own clears the star filter; having
// tapped digits under it, letting go only ends the chord.
if (self.f-held && (event.text == "f" || event.text == "F")) {
if (!self.f-used) {
root.filter-rating-range(0, 5);
}
self.f-held = false;
return accept;
}
return reject;
}
height: 0px;
// The grid is the primary surface of this screen, so it takes
// focus on show rather than waiting for a click. Without this
@@ -2895,6 +2964,11 @@ export component LibraryGrid inherits Rectangle {
init => { self.focus(); }
key-pressed(event) => {
// Declined, not swallowed, so Escape reaches the shell
// and closes the sheet rather than judging under it.
if (root.sheet-open) {
return reject;
}
// TRACES: FR-UI-2 | FR-UI-4
// Back and Escape close what is open here, innermost
// first, before the shell above gets to read them as
@@ -2916,24 +2990,113 @@ export component LibraryGrid inherits Rectangle {
}
return reject;
}
if (event.text == "0") { root.judged(0, -1); return accept; }
if (event.text == "1") { root.judged(1, -1); return accept; }
if (event.text == "2") { root.judged(2, -1); return accept; }
if (event.text == "3") { root.judged(3, -1); return accept; }
if (event.text == "4") { root.judged(4, -1); return accept; }
if (event.text == "5") { root.judged(5, -1); return accept; }
// GESTURE: Export the selection as the last export was
// where: Library grid
// touch: Select them, then Export in the selection bar
// pointer: Select them, then Export in the selection bar
// keys: Ctrl+Shift+E, or Ctrl+E to see the export
// settings first
// why: Lightroom's and darktable's chords. Every
// export runs on the saved defaults, so the
// plain chord opens them beside an Export
// button and the shifted one skips straight to
// exporting.
//
// TRACES: FR-EXP-7 | FR-UI-5
// Before the bare keys below: a chord must not also be read
// as the letter it is spelled with.
if (event.modifiers.control
&& (event.text == "e" || event.text == "E")) {
if (event.modifiers.shift) {
root.export-selection();
} else {
root.open-export-options();
}
return accept;
}
// GESTURE: Paste copied settings onto the selection
// where: Library grid
// touch: Select them, then "Paste to N" in the
// selection bar
// pointer: Select them, then "Paste to N"
// keys: Ctrl+V
//
// TRACES: FR-DEV-6 | FR-UI-5
// Silent with nothing copied, as the button is disabled.
if (event.modifiers.control && root.settings-armed
&& (event.text == "v" || event.text == "V")) {
root.paste-settings-to-selection();
return accept;
}
// TRACES: FR-CAT-5 | FR-UI-5
// Ctrl+A takes everything the grid shows, as "Select all"
// in the selection strip does.
if (event.modifiers.control
&& (event.text == "a" || event.text == "A")) {
root.select-all();
return accept;
}
// GESTURE: Show only photographs with some number of stars
// where: Library grid
// touch: Tap a star chip in the filter bar
// pointer: Click a star chip in the filter bar
// keys: Hold F and tap a digit for exactly that many
// stars, or two digits for everything between
// them; tap F alone to show every rating again
// why: The chips say "this many or more". A range
// with a ceiling — the twos and threes still
// to be decided — is the keyboard's alone, and
// the bar says so in words while it holds.
//
// Held rather than typed in sequence, so nothing waits
// to find out whether a second digit is coming: every
// digit filters as it lands. Any other key ends the chord,
// in case its release went to a window that had focus by
// then and the grid never heard it.
if (!event.modifiers.control
&& (event.text == "f" || event.text == "F")) {
// Key repeat while held says nothing new.
if (!self.f-held) {
self.f-held = true;
self.f-first = -1;
self.f-used = false;
}
return accept;
}
if (self.f-held) {
if (event.text == "0" || event.text == "1"
|| event.text == "2" || event.text == "3"
|| event.text == "4" || event.text == "5") {
if (self.f-first < 0) {
self.f-first = event.text.to-float();
root.filter-rating-range(self.f-first, self.f-first);
} else {
root.filter-rating-range(self.f-first,
event.text.to-float());
}
self.f-used = true;
return accept;
}
self.f-held = false;
}
if (event.text == "0") { root.judged(0, -1, root.hover-ordinal); return accept; }
if (event.text == "1") { root.judged(1, -1, root.hover-ordinal); return accept; }
if (event.text == "2") { root.judged(2, -1, root.hover-ordinal); return accept; }
if (event.text == "3") { root.judged(3, -1, root.hover-ordinal); return accept; }
if (event.text == "4") { root.judged(4, -1, root.hover-ordinal); return accept; }
if (event.text == "5") { root.judged(5, -1, root.hover-ordinal); return accept; }
// Case-insensitive: caps lock during a long cull must not
// silently stop the keys working.
if (event.text == "p" || event.text == "P") {
root.judged(-1, 1);
root.judged(-1, 1, root.hover-ordinal);
return accept;
}
if (event.text == "x" || event.text == "X") {
root.judged(-1, 2);
root.judged(-1, 2, root.hover-ordinal);
return accept;
}
if (event.text == "u" || event.text == "U") {
root.judged(-1, 0);
root.judged(-1, 0, root.hover-ordinal);
return accept;
}
// Delete moves the selection to the trash folder on the
@@ -2949,6 +3112,13 @@ export component LibraryGrid inherits Rectangle {
// rename key everywhere else, and the reason it is bound
// here is that this scope is what holds focus in library
// mode. Rust ignores it when nothing is scoped.
// TRACES: FR-UI-4 | FR-UI-5
// F1 opens the reference the Help button does — the help
// key everywhere else.
if (event.text == Key.F1) {
root.helping = true;
return accept;
}
if (event.text == Key.F2) {
root.rename-scope();
return accept;
@@ -3039,6 +3209,9 @@ export component LibraryGrid inherits Rectangle {
// does not gate it.
if root.total > 0: grid-scroll := Flickable {
interactive: root.held-row < 0;
// See `hover-ordinal`: the cell under a still pointer changes
// as this moves, and nothing re-announces the hover.
changed viewport-y => { root.hover-ordinal = -1; }
// Ctrl+wheel resizes the cells; a plain wheel is declined and
// falls through to the Flickable's own scrolling. Two jobs on
// one gesture, distinguished by the modifier — the convention
@@ -3505,6 +3678,19 @@ export component LibraryGrid inherits Rectangle {
cell-touch := TouchArea {
mouse-cursor: pointer;
// TRACES: FR-UI-5
// Which photograph a judgement key would reach
// without a selection. Cleared only by the cell that
// set it, so moving from one cell to the next cannot
// be undone by the old cell's leave arriving second.
changed has-hover => {
if (self.has-hover) {
root.hover-ordinal = i + root.offset;
} else if (root.hover-ordinal == i + root.offset) {
root.hover-ordinal = -1;
}
}
// Selected on *press*, not on release: the drag that may
// follow reads the selection to build its payload, and by
// release the pointer is over the sidebar.
@@ -3658,7 +3844,8 @@ export component LibraryGrid inherits Rectangle {
// where: Library grid
// touch: Tap a star on the cell
// pointer: Hover the cell, then click a star
// keys: 0 to 5 on the selection
// keys: 0 to 5 with the pointer over it, or
// on the selection
// why: A star has to take the press without it
// also reaching the cell, or every rating
// throws the user into develop.
@@ -4372,9 +4559,19 @@ export component LibraryGrid inherits Rectangle {
// the count beside the buttons elides, so the row genuinely has a
// smaller footprint than its natural one and a window with room for
// the shrunk row should get it shrunk rather than scrolled.
//
// The row scrolls sideways once it outgrows the window, which at a
// desktop width it does. Export is pinned beside it rather than inside
// it: last in a scrolling row put it past the right edge, and with a
// mouse nothing says the row scrolls — so batch export looked like a
// feature the library did not have. A layout, not the Rectangle, is
// the parent of the two, or both sit at the origin and overlap.
HorizontalLayout {
padding-right: Theme.gap;
spacing: Theme.gap-sm;
Flickable {
width: 100%;
height: 100%;
horizontal-stretch: 1;
viewport-height: self.height;
viewport-width: max(self.width, selection-row.min-width);
@@ -4472,6 +4669,7 @@ export component LibraryGrid inherits Rectangle {
// where: Library grid
// touch: While selecting, press "Select all"
// pointer: While selecting, press "Select all"
// keys: Ctrl+A
// why: 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
@@ -4536,15 +4734,30 @@ export component LibraryGrid inherits Rectangle {
}
}
// TRACES: FR-DEV-6
// Paste the copied edit onto the selection. One of the things a
// selection can have done to it, like filing it in a collection,
// so it is on the bar whenever there is a selection — disabled
// until something has been copied in develop. Hidden until then,
// it was a feature nobody could find (FR-UI-4).
//
// The count is in the label rather than in a confirmation: this
// writes to every selected image, and "Paste to 40" said before
// the click is worth more than a dialogue asking the same
// question after it.
if !root.ranging && root.selected-count > 0: Button {
text: "Paste to " + root.selected-count;
enabled: root.settings-armed;
y: (parent.height - self.height) / 2;
clicked => { root.paste-settings-to-selection(); }
}
// TRACES: FR-DEV-6
// The saved settings, beside the copied ones.
//
// Gated on the selection alone, unlike the paste after it: that
// button needs a clipboard *this session*, where the preset list
// is whatever the photographer saved last month. Requiring an
// armed clipboard here would hide the saved presets behind an
// unrelated action — which is the shape of bug that makes a
// feature only its author knows about (FR-UI-4).
// Enabled on the selection alone, unlike the paste before it:
// that needs a clipboard *this session*, where the preset list is
// whatever the photographer saved last month.
if !root.ranging && root.selected-count > 0: Button {
text: "Presets";
y: (parent.height - self.height) / 2;
@@ -4560,22 +4773,6 @@ export component LibraryGrid inherits Rectangle {
clicked => { root.merge-selection(); }
}
// TRACES: FR-DEV-6
// Batch-apply the copied settings. Shown only with both a
// selection and a clipboard, because it is meaningless without
// either — and because a permanently visible button that is
// usually disabled teaches the user to stop reading this bar.
//
// The count is in the label rather than in a confirmation: this
// writes to every selected image, and "Paste to 40" said before
// the click is worth more than a dialogue asking the same
// question after it.
if !root.ranging && root.selected-count > 0 && root.settings-armed: Button {
text: "Paste to " + root.selected-count;
y: (parent.height - self.height) / 2;
clicked => { root.paste-settings-to-selection(); }
}
// Removing from a collection is only meaningful while the grid is
// scoped to one. Offering it unscoped would invite the reading
// "remove from the library", which nothing here does.
@@ -4606,38 +4803,40 @@ export component LibraryGrid inherits Rectangle {
clicked => { root.open-membership(); }
}
// TRACES: FR-EXP-7 | NFR-ARCH-3
// Export the selection, and stop the batch that is running.
//
// One button doing both, because they are the same thought a
// moment apart and a separate cancel would have to appear from
// somewhere — shifting the row under the pointer at the exact
// moment the user is reaching for it.
//
// Last on the bar because it is the last thing done to a
// selection, and because it is the one control here that outlives
// the selection: the bar itself stays up for a running batch (see
// the note on the bar) so this is always reachable while there is
// something to cancel.
//
// The count is in the label rather than behind a confirmation,
// exactly as the paste above puts it there: "Export 40" read
// before the click is worth more than a dialogue asking the same
// question after it.
if !root.ranging: Button {
text: root.exporting
? "Cancel export"
: (root.export-to-server
? "Export " + root.selected-count + " to the library"
: "Export " + root.selected-count);
active: root.exporting;
y: (parent.height - self.height) / 2;
clicked => {
if (root.exporting) {
root.cancel-export();
} else {
root.export-selection();
}
}
}
// TRACES: FR-EXP-7 | NFR-ARCH-3
// Export the selection, and stop the batch that is running.
//
// One button doing both, because they are the same thought a
// moment apart and a separate cancel would have to appear from
// somewhere — shifting the row under the pointer at the exact
// moment the user is reaching for it.
//
// Pinned at the right end of the bar, outside the scrolling row
// (see above), because it is the last thing done to a selection
// and the one control here that outlives the selection: the bar
// stays up for a running batch, so this is always reachable while
// there is something to cancel.
//
// The count is in the label rather than behind a confirmation,
// exactly as the paste above puts it there: "Export 40" read
// before the click is worth more than a dialogue asking the same
// question after it.
if !root.ranging: Button {
text: root.exporting
? "Cancel export"
: (root.export-to-server
? "Export " + root.selected-count + " to the library"
: "Export " + root.selected-count);
active: root.exporting;
y: (parent.height - self.height) / 2;
clicked => {
if (root.exporting) {
root.cancel-export();
} else {
root.export-selection();
}
}
}
+91
View File
@@ -328,3 +328,94 @@ export component PresetSheet inherits Rectangle {
}
}
}
// TRACES: FR-DEV-6 | FR-UI-5
// Which kinds of edit a copy carries, and the Copy button — what Ctrl+Shift+C
// opens, as Lightroom's "Copy Settings" does.
//
// The same scope the preset sheet and the settings page draw, not a second
// one: a paste applies it whenever it happens, so choosing here and pressing
// Ctrl+V later mean the same thing. Turning Compose off is how a crop and a
// rotation stay behind.
export component CopySheet inherits Rectangle {
in property <[ScopeKind]> scope-kinds;
in property <bool> scope-empty: false;
/// What a paste would apply at this scope — "3 adjustments".
in property <string> summary;
/// False where there is no edit to copy from.
in property <bool> can-copy: true;
callback scope-toggled(string);
callback copy();
callback dismiss();
background: #000000CC;
TouchArea {
clicked => { root.dismiss(); }
}
Rectangle {
width: min(420px, parent.width - 2 * Theme.gap-lg);
height: min(sheet.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 { }
sheet := VerticalLayout {
padding: Theme.gap-lg;
spacing: Theme.gap;
Text {
text: "Copy settings";
color: Theme.ink;
font-size: Theme.text-lg;
font-weight: 600;
}
Caption {
text: "The kinds of edit a paste carries. Leave Compose off "
+ "to keep each photograph's own crop and rotation.";
wrap: word-wrap;
}
ScopeChips {
kinds: root.scope-kinds;
toggled(name) => { root.scope-toggled(name); }
}
// The summary is of what is on the clipboard, so before the first
// copy there is none — and an empty line would be a gap that looks
// like a missing message.
if root.scope-empty || root.summary != "": Caption {
text: root.scope-empty
? "Nothing chosen, so a paste would change nothing."
: root.summary;
warn: root.scope-empty;
wrap: word-wrap;
}
HorizontalLayout {
spacing: Theme.gap-sm;
alignment: end;
Button {
text: "Close";
clicked => { root.dismiss(); }
}
Button {
text: "Copy";
primary: true;
enabled: root.can-copy && !root.scope-empty;
clicked => { root.copy(); }
}
}
}
}
}
+7 -353
View File
@@ -1,6 +1,7 @@
import { Theme } from "theme.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, ProgressBar, ActivityRow } from "widgets.slint";
import { Segmented, TextRow, Check, SliderRow } from "controls.slint";
import { ExportImageOptions, ExportFileOptions } from "export.slint";
import { ScopeChips, ScopeKind } from "presets.slint";
// Settings: how much disk the app may spend, and what an export defaults to.
@@ -204,82 +205,8 @@ export component SettingsPage inherits Rectangle {
in property <int> xmp-conflicts: 0;
callback xmp-reload();
// --- export --------------------------------------------------------
in property <[string]> format-labels;
in property <int> format-selected: 0;
in property <int> quality: 90;
in property <bool> quality-enabled: true;
in property <[string]> colour-labels;
in property <int> colour-selected: 0;
in property <[string]> sizing-labels;
in property <int> sizing-selected: 0;
in-out property <int> sizing-value: 0;
in property <bool> sizing-has-value: false;
in property <string> sizing-unit: "px";
/// "Size value" for the modes that take one number, "Width" for the box
/// modes, where a second field sits below it.
in property <string> sizing-value-label: "Size value";
in-out property <int> sizing-height: 0;
in property <bool> sizing-has-height: false;
/// The panels offered as buttons, and which one the numbers match — `-1`
/// where they match none, so a preset never stays lit over fields that
/// have since been typed over.
in property <[string]> screen-labels;
in property <int> screen-selected: -1;
in property <bool> allow-upscaling: false;
in property <[string]> sharpening-labels;
in property <int> sharpening-selected: 0;
in-out property <string> filename-template;
in property <[string]> collision-labels;
in property <int> collision-selected: 0;
in property <bool> strip-location: true;
in-out property <string> destination;
// What the destination field means depends on this, so the placeholder
// comes from Rust alongside it rather than being written twice here.
in property <string> destination-hint;
in property <[string]> target-labels;
in property <int> target-selected: 0;
// --- the remote folder picker ---------------------------------------
//
// The same navigation the launch screen uses to choose a library root,
// driven by the same `FolderBrowser` model in Rust. A folder on the
// server is not something anyone can be expected to type from memory.
in property <bool> browse-open: false;
in property <string> browse-path;
in property <[string]> browse-entries;
in property <bool> browse-loading: false;
/// At the library root, so there is nowhere up to go.
in property <bool> browse-at-root: true;
/// Whether the destination is one that can be walked.
///
/// A boolean from Rust rather than a test on `target-selected`. The index
/// was hardcoded to 1, which was Remote's position while both targets were
/// offered — and the moment Android's list narrowed to Remote alone, that
/// index became 0 and the button vanished on the one platform where it is
/// the *only* way to set a destination. An index into a list whose length
/// varies is not a fact about the target.
in property <bool> browse-available: false;
callback format-picked(int);
callback quality-changed(int);
callback colour-picked(int);
callback sizing-picked(int);
callback sizing-value-changed(string);
callback sizing-height-changed(string);
callback screen-picked(int);
callback upscaling-toggled(bool);
callback sharpening-picked(int);
callback template-changed(string);
callback collision-picked(int);
callback strip-location-toggled(bool);
callback destination-changed(string);
callback target-picked(int);
callback browse-open-picker();
callback browse-into(string);
callback browse-up();
callback browse-confirm();
callback browse-cancel();
// The export defaults are drawn from `ExportOptions` (export.slint),
// which the export sheet shares; nothing about them is declared here.
/// A save failed. The page's whole contract is that what it shows is
/// stored, so this cannot be swallowed.
@@ -785,113 +712,12 @@ export component SettingsPage inherits Rectangle {
PanelHeading { text: "EXPORT DEFAULTS"; }
Caption {
text: "What an export starts from. Every one of these "
+ "is still changeable per export.";
text: "What every export uses. Ctrl+E shows these on their own, "
+ "beside an Export button.";
wrap: word-wrap;
}
Segmented {
label: "Format";
options: root.format-labels;
selected: root.format-selected;
picked(i) => { root.format-picked(i); }
}
// A bounded number, so it gets the control for one.
//
// This was a free-text field: the range lived in the
// hint and was enforced nowhere, and `to-float()`
// answers 0 for anything unparseable — so a typo saved
// a quality of 0 and the page then showed the 0 back as
// though it had been asked for. The track carries the
// range and the box refuses what it cannot read.
SliderRow {
label: "Quality";
// Says why it is greyed rather than leaving the
// user to work out that PNG has no quality.
hint: root.quality-enabled ? "1 to 100"
: "the chosen format is lossless";
value: root.quality;
// No meaningful neutral: quality has a sensible
// default but not a *zero*, and a default marker
// partway along a track reads as one.
default-value: 1;
minimum: 1;
maximum: 100;
enabled: root.quality-enabled;
changed(v) => { root.quality-changed(v); }
}
Segmented {
label: "Colour space";
hint: "profile embedded on export";
options: root.colour-labels;
selected: root.colour-selected;
picked(i) => { root.colour-picked(i); }
}
Segmented {
label: "Size";
options: root.sizing-labels;
selected: root.sizing-selected;
picked(i) => { root.sizing-picked(i); }
}
// Only where the chosen mode carries a number:
// "Original" has none, and a field showing 0 beside it
// would invite the reading "zero pixels".
if root.sizing-has-value: TextRow {
label: root.sizing-value-label;
text: root.sizing-value;
unit: root.sizing-unit;
field-width: 90px;
accepted(t) => { root.sizing-value-changed(t); }
}
// TRACES: FR-EXP-3
// The second axis, for the box modes only.
if root.sizing-has-height: TextRow {
label: "Height";
text: root.sizing-height;
unit: "px high";
field-width: 90px;
accepted(t) => { root.sizing-height-changed(t); }
}
// TRACES: FR-EXP-3
// Panel sizes as buttons, because the numbers are the
// whole difficulty: a television's art mode accepts one
// resolution and rejects everything else, and getting
// it by typing four digits twice is a step at which a
// photographer discovers they were wrong only after the
// upload.
//
// Alongside the fields rather than instead of them —
// the presets are a shortcut to a pair of numbers, not
// a replacement for being able to say any pair.
if root.sizing-has-height: Segmented {
label: "Screen";
hint: "fills in both numbers";
options: root.screen-labels;
selected: root.screen-selected;
picked(i) => { root.screen-picked(i); }
}
Check {
label: "Allow upscaling";
hint: "Off, a request larger than the source exports "
+ "at source size rather than failing.";
checked: root.allow-upscaling;
toggled(on) => { root.upscaling-toggled(on); }
}
Segmented {
label: "Output sharpening";
hint: "scaled by the resize factor";
options: root.sharpening-labels;
selected: root.sharpening-selected;
picked(i) => { root.sharpening-picked(i); }
}
ExportImageOptions {}
}
}
@@ -1131,179 +957,7 @@ export component SettingsPage inherits Rectangle {
}
}
TextRow {
label: "Filename template";
hint: "{name} {seq} {date} {dimensions} {preset}";
text <=> root.filename-template;
field-width: 260px;
placeholder: "{name}";
accepted(t) => { root.template-changed(t); }
}
Segmented {
label: "If the file exists";
options: root.collision-labels;
selected: root.collision-selected;
picked(i) => { root.collision-picked(i); }
}
// Where the file lands, before what it is called: on
// Android the answer decides whether an export needs
// the Storage Access Framework at all, and on any
// platform a server destination is reached over a
// network that may not be there.
Segmented {
label: "Export to";
options: root.target-labels;
selected: root.target-selected;
picked(i) => { root.target-picked(i); }
}
TextRow {
label: "Destination";
// What an empty field does, not what it was once
// going to do: nothing asks, and an export with
// no folder is refused and says so in the header.
hint: "a folder on this device; exports are refused until one is set";
text <=> root.destination;
field-width: 320px;
placeholder: root.destination-hint;
accepted(t) => { root.destination-changed(t); }
}
// Offered only for a server destination. A folder on
// this device is chosen by the platform's own dialogue
// or typed; a folder on the server can only be found
// by walking it, and expecting anyone to recall the
// exact spelling of a path three levels down is how a
// destination silently becomes a new folder at the
// root.
if root.browse-available && !root.browse-open: HorizontalLayout {
alignment: start;
Button {
text: "Choose folder…";
clicked => { root.browse-open-picker(); }
}
}
if root.browse-open: Rectangle {
background: Theme.ground;
border-radius: Theme.radius;
height: picker.preferred-height + 2 * Theme.gap;
picker := VerticalLayout {
x: Theme.gap;
y: Theme.gap;
width: parent.width - 2 * Theme.gap;
spacing: Theme.gap-sm;
HorizontalLayout {
spacing: Theme.gap-sm;
Button {
text: "↑ Up";
// Disabled rather than hidden at the
// root: a control that vanishes moves
// everything beside it, and the row
// would jump as the user navigates.
enabled: !root.browse-at-root;
clicked => { root.browse-up(); }
}
Value {
text: root.browse-path;
overflow: elide;
horizontal-stretch: 1;
vertical-alignment: center;
}
Caption {
text: root.browse-loading ? "Listing…" : "";
vertical-alignment: center;
}
}
// A fixed height rather than one that grows
// with the listing: a folder with sixty
// children would otherwise push the rest of
// the settings page off the bottom.
Rectangle {
height: 180px;
background: Theme.surface;
border-radius: Theme.radius;
Flickable {
x: 4px;
y: 4px;
width: parent.width - 8px;
height: parent.height - 8px;
viewport-height: folders.preferred-height;
folders := VerticalLayout {
width: 100%;
spacing: 2px;
alignment: start;
if root.browse-entries.length == 0
&& !root.browse-loading: Caption {
text: "No folders here. "
+ "Use this one, or go up.";
}
for name in root.browse-entries: Rectangle {
height: 32px;
background: touch.has-hover
? Theme.surface-raised
: transparent;
border-radius: Theme.radius;
Label {
x: Theme.gap-sm;
text: name;
vertical-alignment: center;
overflow: elide;
width: parent.width - 2 * Theme.gap-sm;
}
touch := TouchArea {
clicked => { root.browse-into(name); }
}
}
}
}
}
HorizontalLayout {
spacing: Theme.gap-sm;
alignment: end;
Button {
text: "Cancel";
clicked => { root.browse-cancel(); }
}
// Confirms the folder currently *shown*,
// not one selected in the list — the same
// rule the library picker follows, so
// "use this one" means the same thing in
// both places.
Button {
text: "Use this folder";
active: true;
clicked => { root.browse-confirm(); }
}
}
}
}
Check {
label: "Strip location and personal metadata";
hint: "On. An export is usually the copy that leaves "
+ "this machine, and a location embedded in a "
+ "published photograph cannot be recalled.";
checked: root.strip-location;
toggled(on) => { root.strip-location-toggled(on); }
}
ExportFileOptions {}
}
}
}