Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
031315bdb6 | ||
|
|
00c028c8c8 | ||
|
|
bddf3250c5 | ||
|
|
41486bd59b | ||
|
|
d6d27fb062 | ||
|
|
317a2f40bd | ||
|
|
949fe40d5b | ||
|
|
2be80d4203 | ||
|
|
9ebaa15099 | ||
|
|
aee355fada |
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+52
-52
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Executable
+100
@@ -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}"
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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%;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user